idct / symfony-view-projection-normalizer
Symfony MVC View Projection Normalizer - provides DefaultViewProjection attribute and normalizer for intermediate layer between data models and view serialization
Package info
github.com/ideaconnect/symfony-view-projection-normalizer
pkg:composer/idct/symfony-view-projection-normalizer
Requires
- doctrine/common: ^3.5
- symfony/event-dispatcher: ^7 || ^8
- symfony/http-kernel: ^7 || ^8
- symfony/serializer: ^7 || ^8
- symfony/service-contracts: ^3
Requires (Dev)
- behat/behat: ^3.29
- friendsofphp/php-cs-fixer: ^3.94
- phpspec/prophecy-phpunit: ^2.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0 || ^12.0 || ^13.0
- symfony/property-access: ^7 || ^8
- symfony/property-info: ^7 || ^8
README
This package adds the DefaultViewProjection attribute and a Symfony Serializer normalizer that turns marked entities into dedicated view projection objects before serialization.
Instead of pushing response-shaping logic into entities or growing large serializer-group configurations, you define an explicit read model for each entity and let the serializer use that model automatically.
Why Use It
Use this library when your serialized output is not a 1:1 copy of your entity structure.
- Keep entities focused: domain objects stay free of presentation-specific getters and aliases
- Model the response explicitly: ViewProjections become small read models for the view or API layer
- Handle derived fields cleanly: combine fields, rename them, and expose calculated values without serializer-group sprawl
- Improve cacheability: caching a stable projection output is usually easier than caching raw entities with ad hoc serialization rules
In practice, this sits between your domain model and your serialized response. The entity remains the source of truth; the ViewProjection defines how that entity should look when returned to the client.
β¨ Features
- Attribute-based Configuration: Use
#[DefaultViewProjection]to specify ViewProjections - SerializedName Support: Full support for
#[SerializedName('alias')]attributes - Nested Object Handling: Automatic handling of nested ViewProjections
- Collection Support: Transform arrays/collections of objects
- Resettable Metadata Cache: Resolved projection classes are cached and can be cleared through Symfony's
ResetInterface - 100% Test Coverage: Comprehensive PHPUnit + Behat test coverage
- CI/CD Ready: Complete GitHub Actions workflows
How It Works
- Your source class implements
NormalizableInterface. - You attach
#[DefaultViewProjection(...)]to that class. - The normalizer resolves the configured projection class.
- The source object is wrapped in the projection and normalization continues through Symfony Serializer.
Where It Fits
This library is a good fit when you want a lightweight projection layer on top of Symfony Serializer.
- Better than serializer groups for reshaping output: especially when output fields are computed, combined, or nested differently from the entity graph
- Useful for MVC and API responses: one entity can expose a compact response model without polluting the entity itself
- Helpful for nested object graphs: nested entities with their own projections are normalized consistently
It is intentionally simple: each entity points to one default projection class. That keeps the integration small and predictable.
Tradeoffs
- One default projection per entity: this package does not currently choose projections dynamically by context, role, or API version
- Runtime reflection and instantiation: projection metadata is resolved during normalization
- Explicit opt-in: entities must implement
NormalizableInterfaceand projections must implementViewProjectionInterface - Positive-only cache: only successful projection resolutions are cached; unsupported classes are re-checked when encountered again
Those tradeoffs are reasonable if your goal is a thin, explicit read-model layer rather than a full projection-selection framework.
π Quick Start
Installation
composer require idct/symfony-view-projection-normalizer
Symfony Configuration
Register the normalizer in your services.yaml:
IDCT\Mvc\Normalizer\DefaultViewProjectionNormalizer: tags: - { name: serializer.normalizer, priority: 100 }
The attribute is not enough on its own. A class must implement NormalizableInterface to be considered by the normalizer.
If you run Symfony in a long-lived process, the normalizer also implements ResetInterface, so its resolved-projection cache can be cleared between jobs.
Minimal Example
Add a source class:
use IDCT\Mvc\Attribute\DefaultViewProjection; use IDCT\Mvc\Model\NormalizableInterface; #[DefaultViewProjection(viewProjectionClass: UserViewProjection::class)] class User implements NormalizableInterface { public function __construct( private string $firstName, private string $lastName, private int $age ) { } public function getFirstName(): string { return $this->firstName; } public function getLastName(): string { return $this->lastName; } public function getAge(): int { return $this->age; } }
Create a projection class:
use IDCT\Mvc\Model\NormalizableInterface; use IDCT\Mvc\Model\ViewProjectionInterface; use Symfony\Component\Serializer\Attribute\SerializedName; class UserViewProjection implements ViewProjectionInterface { private User $user; public function __construct(NormalizableInterface $source) { if (!$source instanceof User) { throw new \InvalidArgumentException('UserViewProjection expects an instance of ' . User::class . '.'); } $this->user = $source; } #[SerializedName('n')] public function getName(): string { return $this->user->getFirstName() . ' ' . $this->user->getLastName(); } #[SerializedName('a')] public function getAge(): int { return $this->user->getAge(); } }
When Symfony Serializer normalizes a User, the normalizer will first wrap it in UserViewProjection and then serialize the projection output.
Constructor Contract
ViewProjectionInterface requires a constructor that accepts NormalizableInterface.
That is the minimum interface contract only. It does not prove that a projection matches the exact source class configured by DefaultViewProjection. In practice, projection classes should narrow the incoming source object immediately, usually with an instanceof guard as shown above.
Result:
{
"n": "John Doe",
"a": 30
}
Manual Serializer Setup
If you are not wiring this through Symfony services, add the normalizer to your serializer stack before the default object normalizer.
use IDCT\Mvc\Normalizer\DefaultViewProjectionNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; $serializer = new Serializer([ new DefaultViewProjectionNormalizer(), new ObjectNormalizer(), ]);
Common Use Cases
- Return a compact API response without exposing the full entity structure
- Rename fields with
#[SerializedName]while keeping entity method names domain-oriented - Add calculated values such as aggregates, labels, or formatted names
- Normalize nested entities and collections through their own projections
- Create cache-friendly response models for controllers and view layers
Runtime Notes
- The normalizer caches only successful
DefaultViewProjectionresolutions in memory. - Classes without the attribute are not retained in the cache.
- Calling
reset()clears the resolved projection map, which is useful in long-running workers. - Each source class points to one default projection class.
π§ͺ Testing
Run All Tests
composer run test:unit && composer run test:feature
Individual Test Suites
# Static analysis composer run test:static # PHPUnit unit tests composer run test:unit # Behat acceptance tests composer run test:feature # PHPUnit with coverage reports composer run test:coverage # Coverage verification php bin/check-coverage.php
π Documentation
- Behat Tests: Acceptance test documentation
- Coverage Reports: Detailed coverage analysis
π€ Contributing
- Fork the repository
- Create a feature branch
- Run checks:
composer run test:static && composer run test:unit && composer run test:feature - Ensure 100% coverage
- Submit a pull request
π Love my work? Support it! π
- πͺ BTC: bc1qntms755swm3nplsjpllvx92u8wdzrvs474a0hr
- π ETH: 0x08E27250c91540911eD27F161572aFA53Ca24C0a
- β‘ TRX: TVXWaU4ScNV9RBYX5RqFmySuB4zF991QaE
- π LTC: LN5ApP1Yhk4iU9Bo1tLU8eHX39zDzzyZxB
- β Buy me a coffee: https://buymeacoffee.com/idct
- π Sponsor: https://github.com/sponsors/ideaconnect
π License
This project is licensed under the MIT License.