Search by

bear / coding-standard

koriym

PHPCS ruleset and custom sniffs for BEAR.Sunday projects

Package info

github.com/bearsunday/coding-standard

Type:phpcodesniffer-standard

pkg:composer/bear/coding-standard

Statistics

Installs: 24

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.1 2026-11-11 08:55 UTC

This package is auto-updated.

Last update: 2026-11-11 09:09:58 UTC


README

A PHP_CodeSniffer ruleset and custom sniffs for BEAR.Sunday projects.

Extends Doctrine Coding Standard with BEAR.Sunday-specific rules that enforce framework conventions around the Resource layer, dependency injection, and Ray.MediaQuery usage.

Installation

composer require --dev bear/coding-standard --with-all-dependencies

The --with-all-dependencies flag lets Composer raise squizlabs/php_codesniffer, doctrine/coding-standard, and slevomat/coding-standard to the versions this package requires if your project pins older ones.

The dealerdirect/phpcodesniffer-composer-installer plugin auto-registers the standard. After install, BearSunday is available as a PHPCS standard name.

Usage

Option A — extend in your phpcs.xml

<?xml version="1.0"?>
<ruleset name="MyProject">
    <rule ref="BearSunday"/>
    <file>src</file>
    <file>tests</file>
</ruleset>

Option B — pass on the CLI

vendor/bin/phpcs --standard=BearSunday src/

Scoping path-sensitive sniffs

Seven sniffs gate on a loose substring match against the file path — ReturnStatic, NoSuperglobals, NoAbstractResource, NoFinalResource, HeaderConstant, and StatusCodeConstant trigger on any path containing /Resource/; NoNewService triggers on /Resource/, /Service/, or /Domain/. Every BearSunday sniff excludes */tests/*, so test-mirror directories such as tests/Resource/ArticleTest.php are not checked by them. Tests are still checked by the Doctrine rules and the forbidden debug functions. The pattern matches /tests/ anywhere in the absolute path, so a project that itself sits under a directory named tests (for example ~/tests/myapp) loses these sniffs for src too.

To scope a sniff to your actual source layout, add a narrower sibling <rule ref> with an include-pattern — PHPCS applies it cumulatively even though the sniff is already pulled in by the broader <rule ref="BearSunday"/>:

<rule ref="BearSunday.Resources.NoSuperglobals">
    <include-pattern>src/*Resource/*</include-pattern>
</rule>

Use src/*Resource/*, not src/Resource/* — the leading * after src/ tolerates nesting before the target segment, which a literal prefix pattern silently misses. This matters most for NoNewService, whose three trigger segments need their own patterns:

<rule ref="BearSunday.Di.NoNewService">
    <include-pattern>src/*Resource/*</include-pattern>
    <include-pattern>src/*Service/*</include-pattern>
    <include-pattern>src/*Domain/*</include-pattern>
</rule>

Without the leading *, src/Service/* would miss a real file like src/Datadog/Service/Foo.php, where Service is nested under another directory instead of appearing directly under src/.

Disabling or exempting individual rules

Don't want a specific rule at all — for example a project with no plans to adopt constructor-only DI enforcement? Exclude it by name inside the <rule ref="BearSunday"/> block:

<rule ref="BearSunday">
    <exclude name="BearSunday.Di.NoNewService"/>
</rule>

This turns the rule off entirely; every other BearSunday.* sniff stays active.

For a lighter touch — keep the rule, but exempt specific cases — most sniffs expose their own properties instead (see each sniff's own section above for the full list): NoNewService has allowedClasses/allowedSuffixes, HeaderConstant has allowedHeaders. For example, to allow new StructuredData(...) without disabling NoNewService for anything else:

<rule ref="BearSunday.Di.NoNewService">
    <properties>
        <property name="allowedClasses" type="array">
            <element value="StructuredData"/>
        </property>
    </properties>
</rule>

What's included

Doctrine Coding Standard (base)

All Doctrine rules are inherited, except those that conflict with BEAR.Sunday conventions:

  • SlevomatCodingStandard.Attributes.AttributeAndTargetSpacing, which allows inline parameter attributes like #[Input] Dto $input — the BEAR.Sunday preferred form for DTO injection.
  • SuperfluousInterfaceNaming, SuperfluousExceptionNaming and SuperfluousAbstractClassNaming: BEAR.Sunday names interfaces *Interface, exceptions *Exception and abstract bases Abstract*.
  • Generic.Formatting.MultipleStatementAlignment.NotSame and Squiz.Strings.DoubleQuoteUsage.ContainsVar.
  • SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint under /Resource/: ResourceObject declares $body, $code, $headers, $view and $uri without a type, and PHP rejects a type on a property redeclared from an untyped parent.

SlevomatCodingStandard.Commenting.DocCommentSpacing groups annotations the BEAR.Sunday way: @param with @psalm-param and @phpstan-param, @return with @psalm-return and @phpstan-return, then @throws.

Generic.PHP.ForbiddenFunctions

Forbids: var_dump, print_r, dd, error_log, dump. Use Xdebug tracing / profiling for debugging.

BearSunday.Resources.ReturnStatic

Trigger: any method named onGet, onPost, onPut, onPatch, onDelete, onHead, or onOptions inside a file whose path contains /Resource/.

Rule: the declared return type must be exactly static.

ResourceObject subclasses form fluent chains. Returning self breaks inheritance; omitting the type drops static analysis coverage.

Auto-fixable with phpcbf: inserts : static when the return type is missing, or replaces an existing self/ResourceObject/other declared type with static, preserving surrounding whitespace and brace placement.

The fix is purely syntactic — it rewrites the declared type without checking what the method actually returns. Narrowing : ResourceObject (or a missing type) to : static is only safe at runtime if the method returns $this (or another same-class instance); a handler that returns a different ResourceObject would start throwing a TypeError after the fix is applied. Run your test suite after any bulk phpcbf pass using this sniff rather than trusting the fixer alone.

// Bad
public function onGet(int $id): self { ... }
public function onPost(string $slug)  { ... }  // no return type
public function onDelete(int $id): ResourceObject { ... }

// Good
public function onGet(int $id): static { ... }

BearSunday.Resources.NoSuperglobals

Trigger: any T_VARIABLE token inside a file whose path contains /Resource/.

Rule: $_GET, $_POST, $_FILES, $_COOKIE, $_SESSION, $_REQUEST, $_SERVER, $_ENV are forbidden.

BEAR.Sunday injects request data via parameter attributes. Direct superglobal access bypasses the framework's request lifecycle and AOP interceptor chains.

// Bad — inside a Resource
$id   = $_GET['id'];
$body = $_POST['body'];

// Good — declare as method parameters
public function onGet(#[QueryParam('id')] int $id): static { ... }

Alternatives mentioned in the error message: #[QueryParam], #[CookieParam], #[UploadFiles].

BearSunday.Resources.NoAbstractResource

Trigger: any abstract class declaration inside a file whose path contains /Resource/.

Rule: Resource classes must be concrete. Shared abstract base classes should live outside the Resource directory, for example Support\Resource.

Abstract methods are not flagged; this rule targets only abstract class declarations.

// Bad — inside Resource/
abstract class BasePageResource { ... }

// Good — inside Resource/
class ArticlePageResource { ... }

BearSunday.Resources.NoFinalResource

Trigger: any final class declaration inside a file whose path contains /Resource/.

Rule: Resource classes must not be final. Ray.Aop applies interceptors by generating a subclass, and Ray.Di silently skips a final class, so attributes such as #[SameOrigin] and #[CsrfToken] would run unprotected.

// Bad — inside Resource/
final class ArticleResource { ... }

// Good — inside Resource/
class ArticleResource { ... }

BearSunday.Resources.StatusCodeConstant

Trigger: $this->code = <numeric literal>; inside a file whose path contains /Resource/.

Rule: use the matching Koriym\HttpConstants\StatusCode constant instead of a bare HTTP status number. Codes without a constant in that class (this package's StatusCode currently has no 422 or 429, for example) are left alone — flagging them would tell you to write an undefined constant.

Auto-fixable with phpcbf. The fixer always rewrites the literal to an absolute (\-prefixed) class reference, so it never depends on a use import being present in that file. If the resulting reference isn't already shortened by a use import, the inherited SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly rule adds one and shortens the reference on the next phpcbf pass.

// Bad
$this->code = 404;

// Good
$this->code = StatusCode::NOT_FOUND;

Configuration

Override the suggested class name via statusCodeClass in your phpcs.xml if your project re-exports the constants under a different fully qualified class name.

BearSunday.Resources.HeaderConstant

Trigger: $this->headers['<Name>'] = ...; inside a file whose path contains /Resource/.

Rule: use the matching Koriym\HttpConstants\ResponseHeader constant instead of a string literal header name. Header names without a constant in that class (application-specific headers like X-Request-Id) are left alone.

Auto-fixable with phpcbf, same as StatusCodeConstant: the fixer always emits an absolute (\-prefixed) class reference, so it never depends on a use import; the inherited SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly rule adds the use import and shortens the reference on the next phpcbf pass.

// Bad
$this->headers['Location'] = '/articles/1';

// Good
$this->headers[ResponseHeader::LOCATION] = '/articles/1';

Configuration

Keep specific header names as string literals via allowedHeaders, and override the suggested class name via headerClass, in your phpcs.xml:

<rule ref="BearSunday.Resources.HeaderConstant">
    <properties>
        <property name="allowedHeaders" type="array">
            <element value="Location"/>
        </property>
    </properties>
</rule>

BearSunday.DbQuery.RedundantType

Trigger: #[DbQuery] attributes that include a type: named argument.

Rule: the type: argument is redundant when the method's return type already implies the fetch mode:

Return type Implied fetch mode Redundant type: value
?ClassName, ClassName|null getRow() 'row'
array getRowList() 'row_list'

Severity: ERROR.

// Bad
#[DbQuery('article_item', type: 'row')]
public function item(int $id): ?Article;

// Good — return type makes the fetch mode self-evident
#[DbQuery('article_item')]
public function item(int $id): ?Article;

BearSunday.Di.NoNewService

Trigger: new ClassName expressions inside files whose path contains /Resource/, /Service/, or /Domain/.

Excluded: files under /Module/, /Provider/ or /Factory/, and classes that list ProviderInterface in their own implements clause wherever they are (composition roots); a provider that inherits the interface from a base class is still checked. The interface is matched by its short name, since the sniff does not resolve use imports.

Rule: service-layer classes must receive dependencies via Ray.Di constructor injection, not direct new.

Allow-list (no error):

  • Class names ending in Input, Dto, Entity, ValueObject, Exception
  • DateTime, DateTimeImmutable, DateTimeInterface, DateInterval, DateTimeZone
  • SPL classes: ArrayIterator, SplDoublyLinkedList, SplFileInfo, SplFileObject, SplFixedArray, SplHeap, SplMaxHeap, SplMinHeap, SplObjectStorage, SplObserver, SplPriorityQueue, SplQueue, SplStack, SplSubject, SplTempFileObject
  • throw new ... expressions
  • new static and new self: a class constructing itself has nothing to inject (new parent is reported as NewParentFound)
  • Classes listed in allowedClasses
// Bad — inside Service/
public function getService(): OtherService
{
    return new OtherService();   // error
}

// Good — use constructor injection
public function __construct(
    private readonly OtherService $service,
) {}

Configuration

The sniff reports every class it does not recognize. When it reports a value object, add the class to allowedClasses: the list stays in the project's phpcs.xml, where a reviewer sees every class the project treats as a value. Extend the allow-list via allowedClasses or allowedSuffixes in your phpcs.xml:

<rule ref="BearSunday.Di.NoNewService">
    <properties>
        <property name="allowedClasses" type="array">
            <element value="LegacyFactory"/>
            <element value="Vendor\Package\Clock"/>
        </property>
        <property name="allowedSuffixes" type="array">
            <element value="Request"/>
            <element value="Response"/>
        </property>
    </properties>
</rule>

allowedClasses accepts short class names or fully qualified class names without the leading namespace separator.

BearSunday.AppMeta.NoAppDirWritablePath

Trigger: any ->appDir property access ($appMeta->appDir, $this->appMeta->appDir, or another binding name) concatenated with a string literal that is exactly /var/tmp or starts with /var/tmp/.

Rule: /var/tmp paths must be derived from $appMeta->tmpDir, not built by concatenating onto $appMeta->appDir.

BEAR.Sunday's read-only deployment model (Vercel, AWS Lambda, docker run --read-only, readOnlyRootFilesystem: true) keeps appDir read-only and resolves tmpDir under the writable system temp directory instead. Hard-coding a /var/tmp path from appDir works on a writable filesystem but breaks that deployment model. Other appDir concatenation — assets, config, /var/build compiled artifacts shipped with the release — is unaffected.

// Bad
$this->install(new QiqProdModule($this->appMeta->appDir . '/var/tmp/cache/qiq'));

// Good
$this->install(new QiqProdModule($this->appMeta->tmpDir . '/cache/qiq'));

Development

composer install
vendor/bin/phpunit          # run sniff tests
vendor/bin/phpcs            # self-check (uses phpcs.xml)

Reference

License

MIT — see LICENSE.