packagefactory / specification
Implementation of the Specification pattern for PHP
Requires
- php: >=8.1
Requires (Dev)
- phpstan/phpstan: ^1.9
- phpunit/phpunit: ^9.5
- squizlabs/php_codesniffer: ^3.7
README
Implementation of the Specification pattern for PHP
The specification pattern is a way to express business rules in a domain model using boolean logic. It is described in detail in the following document: https://www.martinfowler.com/apsupp/spec.pdf
Installation
composer require packagefactory/specification
Usage
Writing a Specification
Let's presume the following (very simplified) problem: You've got an application with a simple user registration workflow. Users can register freely, but have to verify their E-Mail address. If a user didn't verify their E-Mail address for a period of time, they shall be reminded (via E-Mail) that verification is still due.
How can this business rule be codified using the Specification pattern?
First, let's write a specification that checks if a given user has a verified E-Mail address:
use PackageFactory\Specification\Core\AbstractSpecification;
use Vendor\Project\Domain\User;
/**
* The `@extends` annotation makes sure that static analysis tools like
* phpstan understand that this specification handles `User`-objects
* only:
*
* @extends AbstractSpecification<User>
*/
final class HasVerifiedEmailAddressSpecification extends AbstractSpecification
{
public function isSatisfiedBy($user): bool
{
// In lieu of generics in PHP it is recommended to add a
// zero-cost assertion to ensure the type of the given value:
assert($user instanceof User);
return $user->emailAddress->isVerified;
}
}
Then, let's write a specification that checks if a given user has been registered before a specific reference date:
use PackageFactory\Specification\Core\AbstractSpecification;
use Vendor\Project\Domain\User;
/**
* @extends AbstractSpecification<User>
*/
final class HasBeenRegisteredBefore extends AbstractSpecification
{
public function __construct(
private readonly \DateTimeImmutable $referenceDate
) {
}
public function isSatisfiedBy($user): bool
{
assert($user instanceof User);
return $user->registrationDate->getTimestamp() < $this->referenceDate->getTimestamp();
}
}
We can now use the Specification API to combine both specifications and express our business rule:
// $twoWeeksAgo is a calculated \DateTimeImmutable
$needsReminderSpecification = (new HasBeenRegisteredBefore($twoWeeksAgo))
->andNot(new HasVerifiedEmailAddressSpecification());
$usersThatNeedReminder = $userRepository->findBySpecification($needsReminderSpecification);
foreach ($usersThatNeedReminder as $userThatNeedsReminder) {
$notificationService->sendReminderTo($userThatNeedsReminder);
}
API
Each specification must implement PackageFactory\Specification\Core\SpecificationInterface
. Usually, a custom specification should extend PackageFactory\Specification\Core\AbstractSpecification
, which implements all methods of the SpecificationInterface
except for isSatisfiedBy
.
The SpecificationInterface
covers the following methods:
Note on Generics: PHP does not have built-in Generics. However, there's static analysis tools like phpstan that do understand them. The
SpecificationInterface
comes with an annotation that allows you to specify the type of$candidate
your specification is supposed to cover.Your custom specification implementation should therefore name a concrete
$candidate
type like this:/** * @extends AbstractSpecification<MyClass> */ final class MyCustomSpecification extends AbstractSpecification { /** * @param MyClass $candidate * @return boolean */ public function isSatisfiedBy($candidate): bool { // ... } }
isSatisfiedBy
/**
* @param C $candidate
* @return boolean
*/
public function isSatisfiedBy($candidate): bool;
This method checks the given $candidate
and returns true
if it satisfies the specification and false
if it doesn't.
In lieu of generics in PHP it is recommended to add a zero-cost assertion at the top of the implementation body to ensure the type of $candidate
:
/**
* @param MyClass $candidate
* @return boolean
*/
public function isSatisfiedBy($candidate): bool;
{
assert($candidate instanceof MyClass);
// ...
}
For more on zero-cost assertions see: https://www.php.net/manual/en/function.assert.php
and
/**
* @param SpecificationInterface<C> $other
* @return SpecificationInterface<C>
*/
public function and(SpecificationInterface $other): SpecificationInterface;
The result of this method is a new specification that will be satisfied by a $candidate
that satisfies both the calling specification and $other
.
andNot
/**
* @param SpecificationInterface<C> $other
* @return SpecificationInterface<C>
*/
public function andNot(SpecificationInterface $other): SpecificationInterface;
The result of this method is a new specification that will be satisfied by a $candidate
that satisfies the calling specification and does not satisfy $other
.
or
/**
* @param SpecificationInterface<C> $other
* @return SpecificationInterface<C>
*/
public function or(SpecificationInterface $other): SpecificationInterface;
The result of this method is a new specification that will be satisfied by a $candidate
that satisfies either the calling specification or $other
(or both).
orNot
/**
* @param SpecificationInterface<C> $other
* @return SpecificationInterface<C>
*/
public function orNot(SpecificationInterface $other): SpecificationInterface;
The result of this method is a new specification that will be satisfied by a $candidate
that either satisfies the calling specification or does not satisfy $other
(or both).
not
/**
* @return SpecificationInterface<C>
*/
public function not(): SpecificationInterface;
This method negates the calling specification. That means: the result is a specification that will be satisfied by a $candidate
that does not satisfy the calling specification.
Contribution
We will gladly accept contributions. Please send us pull requests.
License
see LICENSE