bear / coding-standard
PHPCS ruleset and custom sniffs for BEAR.Sunday projects
Package info
github.com/bearsunday/coding-standard
Type:phpcodesniffer-standard
pkg:composer/bear/coding-standard
Requires
- php: ^8.2
- doctrine/coding-standard: ^14.0
- slevomat/coding-standard: ^8.28
- squizlabs/php_codesniffer: ^4.0.1
Requires (Dev)
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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,SuperfluousExceptionNamingandSuperfluousAbstractClassNaming: BEAR.Sunday names interfaces*Interface, exceptions*Exceptionand abstract basesAbstract*.Generic.Formatting.MultipleStatementAlignment.NotSameandSquiz.Strings.DoubleQuoteUsage.ContainsVar.SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHintunder/Resource/:ResourceObjectdeclares$body,$code,$headers,$viewand$uriwithout 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 ...expressionsnew staticandnew self: a class constructing itself has nothing to inject (new parentis reported asNewParentFound)- 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
- BEAR.Sunday コーディングガイド
- MyVendor.Cms — reference implementation
- Doctrine Coding Standard
- Slevomat Coding Standard
License
MIT — see LICENSE.