andanteproject / nullable-embeddable-bundle
A Symfony Bundle to handle nullable embeddables with Doctrine
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
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
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.
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.
