andanteproject / nullable-embeddable-bundle
A Symfony Bundle to handle nullable embeddables with Doctrine
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 0
Forks: 0
Open Issues: 0
Type:symfony-bundle
pkg:composer/andanteproject/nullable-embeddable-bundle
Requires
- php: ^8.1
- doctrine/orm: ^2.0 | ^3.0
- symfony/config: ^5.0 || ^6.0 || ^7.0
- symfony/dependency-injection: ^5.0 || ^6.0 || ^7.0
- symfony/http-kernel: ^5.0 || ^6.0 || ^7.0
- symfony/property-access: ^5.0 || ^6.0 || ^7.0
Requires (Dev)
- doctrine/doctrine-bundle: ^2.0
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/extension-installer: ^1.0
- phpstan/phpstan: ^1.0
- phpstan/phpstan-doctrine: ^1.0
- phpstan/phpstan-symfony: ^1.0
- phpstan/phpstan-webmozart-assert: ^1.0
- phpunit/phpunit: ^9.5
- symfony/cache: ^5.0 || ^6.0 || ^7.0
- symfony/framework-bundle: ^5.0 || ^6.0 || ^7.0
- symfony/test-pack: ^1.0
Suggests
- phpstan/extension-installer: To automatically register the PHPStan extension (recommended)
- phpstan/phpstan: To validate NullableEmbeddable classes at static analysis time
README
Nullable Embeddable Bundle
Symfony Bundle - AndanteProject
A Symfony Bundle that extends Doctrine Embeddables to allow them to be nullable with custom business logic to precisely determine their null state, handling null and uninitialized properties, addressing a common limitation in Doctrine ORM.
Introduction
Doctrine Embeddables are powerful for encapsulating value objects, but they inherently cannot be null. This bundle provides a flexible solution to this limitation by introducing the #[NullableEmbeddable] attribute. This attribute allows you to define custom logic, either through a dedicated processor class or a static anonymous function (PHP 8.5+), to determine when an embeddable object should be considered null. This enables precise control over the null state, even handling uninitialized properties safely.
The bundle works seamlessly with multiple levels of embedded objects, processing from the deepest leaf embeddable up to the root entity.
For example, a Country embeddable can be marked as nullable based on an uninitialized property:
<?php // ... use statements use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; use Andante\NullableEmbeddableBundle\Result; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Embeddable] #[NullableEmbeddable(processor: static function (PropertyAccessor $propertyAccessor, object $embeddableObject): Result { // We check if the 'code' property is uninitialized. if ($propertyAccessor->isUninitialized($embeddableObject, 'code')) { return Result::SHOULD_BE_NULL; } return Result::KEEP_INITIALIZED; })] class Country { public function __construct( #[ORM\Column(type: Types::STRING, length: 2, nullable: true)] private string $code, ) { } // ... getters and setters }
Requirements
- Symfony 5.x-7.x
- PHP 8.1+ (PHP 8.5+ for anonymous function processors)
- Doctrine ORM
Install
Via Composer:
$ composer require andanteproject/nullable-embeddable-bundle
After installation, make sure you have the bundle registered in your Symfony bundles list (config/bundles.php):
return [ // ... Andante\NullableEmbeddableBundle\AndanteNullableEmbeddableBundle::class => ['all' => true], // ... ];
This should be done automatically if you are using Symfony Flex. Otherwise, register it manually.
Usage
The core of this bundle is the #[NullableEmbeddable] attribute, which you place on your Doctrine Embeddable classes alongside #[ORM\Embeddable]. This attribute requires a processor argument, which can be either a class implementing ProcessorInterface or a static anonymous function (PHP 8.5+).
Processor Interface
For older PHP versions or more complex logic that warrants a dedicated class, you can implement the ProcessorInterface.
<?php declare(strict_types=1); namespace Andante\NullableEmbeddableBundle; use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyPathInterface; interface ProcessorInterface { /** * @throws UnexpectedEmbeddableClassException */ public function analyze(PropertyAccessor $propertyAccessor, object $embeddableObject, PropertyPathInterface $propertyPath, object $rootEntity, mixed $embeddedConfig): Result; }
Your processor class must implement this interface.
Example: Address Embeddable with Class Processor
<?php declare(strict_types=1); namespace App\Entity; use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; use Doctrine\ORM\Mapping as ORM; use App\Processor\AddressEmbeddableProcessor; // Your custom processor #[ORM\Embeddable] #[NullableEmbeddable(processor: AddressEmbeddableProcessor::class)] class Address { // ... properties, getters, setters }
And the corresponding AddressEmbeddableProcessor class:
<?php declare(strict_types=1); namespace App\Processor; use Andante\NullableEmbeddableBundle\ProcessorInterface; use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; use Andante\NullableEmbeddableBundle\Result; use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; use App\Entity\Address; use Symfony\Component\PropertyAccess\PropertyPathInterface; class AddressEmbeddableProcessor implements ProcessorInterface { public function analyze(PropertyAccessor $propertyAccessor, object $embeddableObject, PropertyPathInterface $propertyPath, object $rootEntity, mixed $embeddedConfig): Result { if (!$embeddableObject instanceof Address) { throw UnexpectedEmbeddableClassException::create(Address::class, $embeddableObject); } if ( null === $propertyAccessor->getValue($embeddableObject, 'street') && null === $propertyAccessor->getValue($embeddableObject, 'city') && null === $propertyAccessor->getValue($embeddableObject, 'country') ) { return Result::SHOULD_BE_NULL; } return Result::KEEP_INITIALIZED; } }
Anonymous Function Processor (PHP 8.5+)
For projects running on PHP 8.5 or newer, the most convenient way to define your nullability logic is using a static anonymous function directly within the #[NullableEmbeddable] attribute. This keeps your business logic co-located with the embeddable definition, avoiding the need for separate processor classes.
Example: Address Embeddable with Anonymous Function Processor
Consider an Address embeddable that should be considered null if all its properties (street, city, country) are null.
<?php declare(strict_types=1); namespace App\Entity; use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; use Andante\NullableEmbeddableBundle\Result; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Embeddable] #[NullableEmbeddable(processor: static function (PropertyAccessor $propertyAccessor, object $embeddableObject): Result { if (!$embeddableObject instanceof Address) { throw UnexpectedEmbeddableClassException::create(Address::class, $embeddableObject); } if ( null === $propertyAccessor->getValue($embeddableObject, 'street') && null === $propertyAccessor->getValue($embeddableObject, 'city') && null === $propertyAccessor->getValue($embeddableObject, 'country') ) { return Result::SHOULD_BE_NULL; } return Result::KEEP_INITIALIZED; })] class Address { #[ORM\Column(type: Types::STRING, nullable: true)] private ?string $street = null; #[ORM\Column(type: Types::STRING, nullable: true)] private ?string $city = null; #[ORM\Embedded(class: Country::class, columnPrefix: 'country_')] private ?Country $country = null; // ... getters and setters }
In this example, the anonymous function receives a PropertyAccessor and the $embeddableObject. The PropertyAccessor is crucial as it allows you to safely check for uninitialized properties without triggering PHP fatal errors, even with declare(strict_types=1). The function must return a Result enum (Result::SHOULD_BE_NULL or Result::KEEP_INITIALIZED).
Example: Country Embeddable with Anonymous Function Processor
A nested embeddable like Country can also use this approach. Here, Country is considered null if its code property is uninitialized (meaning it was never set, often indicating a new, empty object).
<?php declare(strict_types=1); namespace App\Entity; use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable; use Andante\NullableEmbeddableBundle\Exception\UnexpectedEmbeddableClassException; use Andante\NullableEmbeddableBundle\PropertyAccess\PropertyAccessor; use Andante\NullableEmbeddableBundle\Result; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Embeddable] #[NullableEmbeddable(processor: static function (PropertyAccessor $propertyAccessor, object $embeddableObject): Result { if (!$embeddableObject instanceof Country) { throw UnexpectedEmbeddableClassException::create(Country::class, $embeddableObject); } if ($propertyAccessor->isUninitialized($embeddableObject, 'code')) { return Result::SHOULD_BE_NULL; } return Result::KEEP_INITIALIZED; })] class Country { #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] private ?string $name = null; public function __construct( #[ORM\Column(type: Types::STRING, length: 2, nullable: true)] private string $code, ) { } // ... getters and setters }
The PropertyAccessor
The PropertyAccessor provided to your processor (or anonymous function) is a specialized tool that allows you to inspect the state of embeddable properties, including whether they are uninitialized. This is particularly useful for non-nullable properties that might not have been set when an object is retrieved from the database or instantiated.
$propertyAccessor->getValue($embeddableObject, 'propertyName'): Safely retrieves the value of a property.$propertyAccessor->isUninitialized($embeddableObject, 'propertyName'): Checks if a property is uninitialized.
The Result Enum
The analyze method of your processor must return one of two values from the Result enum:
Result::SHOULD_BE_NULL: Indicates that the embeddable object should be treated as null. Note that "should" is used because the parent entity might have the embeddable class defined as not nullable. There is no guarantee the parent class acceptsnullas a value; this depends on database consistency and the user's data model.Result::KEEP_INITIALIZED: Indicates that the embeddable object should remain initialized.
PHPStan Extension
This bundle includes a PHPStan extension that validates #[NullableEmbeddable] classes to ensure they follow best practices for working with Doctrine's nullable embeddable behavior.
Why This Extension is Important
When Doctrine determines that an entire embeddable object should be null (which is what this bundle does), it sets all the embeddable's database columns to NULL. This has important implications for how you structure your embeddable classes:
-
Property Initialization: Properties with non-null default values should be initialized in the constructor, not outside it. This is because Doctrine hydrates entities by skipping the constructor. For example:
// BAD - Don't do this: private bool $enabled = true; // Doctrine gets NULL from DB but property shows true // GOOD - Do this instead: public function __construct( private bool $enabled = true, ) {}
-
Nullable Columns: All properties mapped to database columns must be nullable. This can be achieved either by using a PHP nullable type (
?string) which Doctrine automatically infers asnullable: true, or by explicitly settingnullable: truein the#[Column]attribute. This is required because when the embeddable object is null, Doctrine will set all its database columns toNULL. -
Nested Embeddables with Defaults: Embedded objects that have explicit non-null default values must be typed as nullable. Uninitialized embedded properties are fine since they remain uninitialized when the parent is null.
Automatic Installation (Recommended)
If you have phpstan/extension-installer installed (which is included in require-dev), the extension will be automatically registered. No additional configuration needed!
composer require --dev phpstan/phpstan phpstan/extension-installer
Manual Installation
If you don't have phpstan/extension-installer, you can manually include the extension in your phpstan.neon or phpstan.neon.dist:
includes: - vendor/andanteproject/nullable-embeddable-bundle/extension.neon
What the Extension Checks
The PHPStan extension will report errors for:
- Properties with non-null default values outside the constructor - These should be moved to the constructor to avoid hydration issues
- Non-nullable column mappings - Properties with
#[Column]must be nullable, either via PHP nullable type (?Type) or explicitnullable: true - Embedded objects with non-null default values - Embedded properties with explicit defaults must be nullable (uninitialized embedded properties are allowed)
Example
#[ORM\Embeddable] #[NullableEmbeddable(processor: /* ... */)] class Address { // ERROR: Property has non-null default outside constructor // private bool $isPrimary = false; // ERROR: Column is not nullable (neither PHP type nor explicit attribute) // #[ORM\Column(type: Types::STRING)] // private string $street; // CORRECT: Column is nullable via PHP type (Doctrine infers nullable: true) #[ORM\Column(type: Types::STRING)] private ?string $street = null; // ALSO CORRECT: Column explicitly nullable (even with non-nullable PHP type) #[ORM\Column(type: Types::STRING, nullable: true)] private string $city; // CORRECT: Uninitialized embedded property (will remain uninitialized when parent is null) #[ORM\Embedded(class: Country::class)] private Country $country; // ALSO CORRECT: Embedded property initialized to null #[ORM\Embedded(class: Region::class)] private ?Region $region = null; // CORRECT: Default value in constructor public function __construct( #[ORM\Column(type: Types::BOOLEAN, nullable: true)] private bool $isPrimary = false, ) {} }
Configuration
The bundle provides a configuration option to enable a cache warmer for improved performance in production environments.
# config/packages/prod/andante_nullable_embeddable.yaml andante_nullable_embeddable: metadata_cache_warmer_enabled: true
Alternatively, using PHP:
<?php use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function (ContainerConfigurator $containerConfigurator): void { if ('prod' === $containerConfigurator->env()) { $containerConfigurator->extension('andante_nullable_embeddable', [ 'metadata_cache_warmer_enabled' => true, ]); } };
metadata_cache_warmer_enabled(default:false): When set totrue, the bundle will read all#[NullableEmbeddable]attributes during Symfony's cache warmup process. This can speed up subsequent requests by pre-populating the metadata cache. It is recommended to enable this only in your production environment.
Nested Embeddables
This bundle fully supports nested embeddables (e.g., an Address embeddable containing a Country embeddable). The processing logic correctly traverses the embeddable tree, starting from the deepest nested embeddable and working its way up to the root entity.
Built with love ❤️ by AndanteProject team.
