Enforce that your classes get only instantiated by the factories you define!
Require this extension via Composer:
composer require --dev ebln/phpstan-factory-ruleIf you also use phpstan/extension-installer, no further setup is needed. Otherwise include the rules in your project's PHPStan config:
includes:
- vendor/ebln/phpstan-factory-rule/rules.neonRequires PHP 8.2+ and PHPStan 2.2.13+.
Add the ForceFactory attribute to your class and list the classes that are allowed to instantiate it:
<?php
// […]
#[\Ebln\Attrib\ForceFactory(GoodFactory::class, BetterFactory::class)]
class OnlyViaFactory
{
}PHPStan then checks every instantiation, in CI pipelines, git hooks or IDE integrations. If somebody introduces a rogue factory:
<?php
// […]
class FailingFactory
{
public function create(): OnlyViaFactory
{
return new OnlyViaFactory();
}
}…PHPStan reports it:
OnlyViaFactory must be instantiated by GoodFactory or BetterFactory.
💡 Move this instantiation into GoodFactory or BetterFactory, or add FailingFactory to the #[ForceFactory] attribute of OnlyViaFactory.
Every error carries an identifier, so you can silence a single kind in phpstan.neon:
parameters:
ignoreErrors:
- identifier: ebln.forceFactory.outsideFactory| Identifier | Reported when |
|---|---|
ebln.forceFactory.outsideFactory |
a marked class is instantiated outside the classes its attribute lists |
ebln.forceFactory.noFactories |
the attribute lists no factory, or none that PHPStan can resolve to an existing class; reported on the attribute and at every instantiation of the class |
ebln.forceFactory.unknownFactory |
the attribute names a class that does not exist (one error per name) |
ebln.forceFactory.invalidFactory |
the attribute names an interface or a trait as a factory |
ebln.forceFactory.namedArgument |
the attribute passes a named argument; the rule reads positional arguments only (one error per name) |
ebln.forceFactory.invalidTarget |
the attribute sits on an interface, trait or enum |
ebln.forceFactory.factoryNotCovariant |
a marked subclass lists a factory that is neither a factory of its nearest marked ancestor nor a subclass of one |
ebln.forceFactory.legacyInterface |
a class still implements the removed Ebln\PHPStan\EnforceFactory\ForceFactoryInterface |
- Nearest attribute wins. A class inherits the attribute of its closest marked ancestor. A marked child of a marked parent is governed by its own list only, so the parent's factory may not create the child unless the child lists it.
- Factories narrow with the product. An instance of a subclass is also an instance of the parent, so the parent's promise covers it. A marked subclass may therefore only list factories of its nearest marked ancestor or subclasses of them; anything else is reported by the definition rule. Declaring the parent's factories
finalcloses the set: no subclass of a factory can exist, so only the listed factories remain. - Closures count as inside. A closure or arrow function inside a listed factory may instantiate the product. An anonymous class inside a factory may not, and neither may a subclass of a factory: list the subclass explicitly.
- A factory is a class. The attribute takes class name constants of classes (or enums). An interface or a trait in the list is reported by the definition rule, because no
newever runs in their scope: naming an interface does not allow its implementations. - Factory names are canonicalised. Casing and a leading backslash do not matter. A name that does not resolve to a class is dropped and reported by the definition rule when PHPStan analyses the marked class's own file.
- A broken list fails closed. When no name is left, nothing may create the class: every
newis reported withnoFactoriesuntil the attribute is fixed. A mistyped list in a path PHPStan does not analyse, such asvendor/, therefore still shows up where the class is used. - Unknown classes are skipped. PHPStan itself reports
new Missing(). This rule skips it, and the rest of the file is still checked.
The rule follows PHPStan's type inference for new $name. Object-typed and class-string<T> typed variables are
checked, but a plain string variable is not, because its type does not say which class it holds. A union of a class
name constant with a plain string is absorbed into string by PHPStan and is not checked either.
Instances created through reflection are not checked: ReflectionClass::newInstance(), newInstanceArgs(),
newInstanceWithoutConstructor() and the PHP 8.4 lazy-object methods bypass both rules. A rule for them is planned
for a later minor release.
The declared type also decides which attribute applies. new $product with $product typed as a marked parent
class is checked against the parent's list, even when the object at runtime is a marked subclass with a list of
its own.
new static() follows the same rule. In an unmarked base class it is typed as the base class. A marked subclass
that inherits a static constructor such as public static function create(): static can therefore be created
anywhere through it. Marking the base class closes this: the new static() is then checked against the base
class's list.
PHP does not load an attribute's class unless something reflects on it, so the attribute class may come from a
require-dev package. The Ebln\Attrib\ForceFactory class ships in this package; nothing else is needed at runtime.
- Register the rules through
rules.neon(see Installation). A manualrules:entry namingForceFactoryRule, the 2.x way without extension-installer, now fails at container build with "Service of type Ebln\PHPStan\EnforceFactory\AllowedFactoriesResolver … not found", and it never registers the definition rule. - The marker interface is gone. Remove
ebln/phpstan-factory-markfrom your project: this package declares a composer conflict with every version of it, andebln.forceFactory.legacyInterfacereports every class that still implements the interface. - Attribute arguments are unchanged: positional class name constants. The rule reads only those. A named argument
such as
factory: MyFactory::classis reported byebln.forceFactory.namedArgument: PHP keeps it in$factoriesat runtime, but PHPStan drops it, and the factory it names would not be allowed. ForceFactory::getAllowedFactories()is replaced by the public readonly property$factories.- Identifiers:
ebln.forceFactory.outOfFactoryInstanciationbecameebln.forceFactory.outsideFactory;ebln.forceFactory.bogusDefinitionwas split into the definition rule's identifiers above. - Inheritance: 2.x merged the attributes of the whole parent chain; 3.0 uses the nearest attribute only.
- Floors: PHP 8.2 and PHPStan 2.2.13.