PHPackages                             chamber-orchestra/view-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. chamber-orchestra/view-bundle

ActiveSymfony-bundle[Database &amp; ORM](/categories/database)

chamber-orchestra/view-bundle
=============================

Symfony bundle providing a typed, reusable view layer for building JSON API responses with automatic property binding and cache-warmed serialization

v8.1.2(1mo ago)2264.3k—8.6%235MITPHPPHP ^8.5CI passing

Since Mar 6Pushed 1mo ago3 watchersCompare

[ Source](https://github.com/chamber-orchestra/view-bundle)[ Packagist](https://packagist.org/packages/chamber-orchestra/view-bundle)[ Docs](https://github.com/chamber-orchestra/view-bundle)[ RSS](/packages/chamber-orchestra-view-bundle/feed)WikiDiscussions master Synced 2w ago

READMEChangelogDependencies (27)Versions (46)Used By (5)

[![PHP Composer](https://github.com/chamber-orchestra/view-bundle/actions/workflows/php.yml/badge.svg)](https://github.com/chamber-orchestra/view-bundle/actions/workflows/php.yml)[![PHPStan](https://camo.githubusercontent.com/745eb989b9e4903dc598fe2cc63ed4226198be55b7c729001cbd1ece7676fef6/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6d61782d627269676874677265656e2e737667)](https://phpstan.org/)[![PHP-CS-Fixer](https://camo.githubusercontent.com/6ea88fbe545f6f06950dd97b31be7621fcb0a0056644de2ea36e44b7de33adc4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d5045522d2d435325323025324625323053796d666f6e792d626c75652e737667)](https://cs.symfony.com/)[![Latest Stable Version](https://camo.githubusercontent.com/19b2451a5251901234787173016dee85793320401924856e63384310a1763b44/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6368616d6265722d6f72636865737472612f766965772d62756e646c652e737667)](https://packagist.org/packages/chamber-orchestra/view-bundle)[![Total Downloads](https://camo.githubusercontent.com/572237f23b26db09606549c03be193a19760f1b6d376606a406badbaa2f77eeb/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6368616d6265722d6f72636865737472612f766965772d62756e646c652e737667)](https://packagist.org/packages/chamber-orchestra/view-bundle)[![License: MIT](https://camo.githubusercontent.com/08cef40a9105b6526ca22088bc514fbfdbc9aac1ddbf8d4e6c750e3a88a44dca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d626c75652e737667)](LICENSE)[![PHP 8.5+](https://camo.githubusercontent.com/2371eeb1a98f81a6894947d4d7b429326ee7f4dbeb3d8940776b4ae7b8442725/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e352532422d3737374242342e737667)](https://www.php.net/)[![Symfony 8.0](https://camo.githubusercontent.com/daaa476b3cc456701380f7d0fbdc3bbe9983e89d3267f99870daa88aa719e181/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53796d666f6e792d382e302d3030303030302e737667)](https://symfony.com/)

ChamberOrchestra View Bundle
============================

[](#chamberorchestra-view-bundle)

A Symfony bundle that provides a **typed view layer for JSON API responses**. Define response shapes as PHP classes, return them from controllers, and let the bundle handle serialization automatically — no manual `JsonResponse` construction needed.

Built for **Symfony 8.0** and **PHP 8.5+**, the bundle eliminates boilerplate in REST API controllers by introducing view models with automatic property binding, collection mapping, and production-ready cache warming.

Key Features
------------

[](#key-features)

- **Typed view models** — define JSON response structures as PHP classes with typed properties
- **Automatic property binding** — `BindView` maps domain object properties to view properties via reflection
- **Collection mapping** — `IterableView` transforms arrays and iterables with typed element views
- **Null stripping** — null values are automatically excluded from serialized JSON output
- **Build-time cache warming** — pre-computed metadata and property mappings eliminate reflection overhead in production
- **Build-versioned caching** — cache files are tied to `container.build_id` for zero-downtime deployments
- **Doctrine proxy support** — transparent lazy-load initialization before property access

Requirements
------------

[](#requirements)

- PHP 8.5+
- Symfony 8.0 components (http-kernel, serializer, property-access, dependency-injection, config, framework-bundle)
- doctrine/common ^3.5

Installation
------------

[](#installation)

```
composer require chamber-orchestra/view-bundle:8.0.*
```

Enable the bundle in `config/bundles.php`:

```
return [
    // ...
    ChamberOrchestra\ViewBundle\ChamberOrchestraViewBundle::class => ['all' => true],
];
```

Quick Start
-----------

[](#quick-start)

Define a view model that maps properties from a domain object:

```
use ChamberOrchestra\ViewBundle\Attribute\BindsFrom;
use ChamberOrchestra\ViewBundle\Attribute\Type;
use ChamberOrchestra\ViewBundle\View\BindView;
use ChamberOrchestra\ViewBundle\View\IterableView;

#[BindsFrom(User::class)]
final class UserView extends BindView
{
    public string $id;
    public string $name;

    #[Type(ImageView::class)]
    public IterableView $images;

    public function __construct(User $user)
    {
        parent::__construct($user);
    }
}

final class ImageView extends BindView
{
    public string $path;
}
```

Return the view from a controller — the bundle converts it to a `JsonResponse` automatically:

```
#[Route('/user/me', methods: ['GET'])]
final class GetMeAction
{
    public function __invoke(): UserView
    {
        return new UserView($this->getUser());
    }
}
```

`ViewSubscriber` converts any `ViewInterface` result into a `JsonResponse`. Non-view results pass through unchanged.

View Types
----------

[](#view-types)

ViewPurpose`ResponseView`Base response with HTTP status (200) and `Content-Type: application/json` headers`DataView`Wraps any view or array under a `"data"` key`BindView`Maps matching properties from a source object using reflection`IterableView`Maps collections via a callback or view class string`KeyValueView`Produces associative array output for metadata blocks`CachedView`Descriptor pairing a source object with its view class; skips view building and serialization on a hit`PrivateCachedView`A `CachedView` scoped to the current user and request locale — private payloads get an isolated cache entry per viewing context`CachedBindView`A `BindView` with deferred binding and a source-derived signature; skips binding and serialization on a hit### BindView Property Binding

[](#bindview-property-binding)

`BindView` uses `BindUtils` to synchronize properties between source objects and view instances. It handles:

- Built-in PHP types and custom objects
- `ViewInterface` subclasses (auto-constructed)
- `IterableView` properties with `#[Type(ViewClass::class)]` attribute for typed collections
- Skips union types and incompatible type pairs

### Cached JSON Responses

[](#cached-json-responses)

Response caching is controlled by bundle configuration (all values below are the defaults):

```
# config/packages/chamber_orchestra_view.yaml
chamber_orchestra_view:
    response_cache:
        enabled: true        # master switch for all response caching
        pool: cache.app      # PSR-6 pool service id storing the JSON payloads
        default_ttl: 86400   # seconds (one day); every entry expires — null is not allowed
```

A JSON payload cache always needs an invalidation key, so views opt in by providing a **signature** — a string that changes whenever the payload would. The core building block is `SourceCacheSignatureInterface`: a static method computing the signature from the source object, so no view has to be built to answer "is this payload cached?".

```
public static function createCacheSignature(object $source): string
{
    \assert($source instanceof User);

    return sprintf('user_%d_%d', $source->getId(), $source->getUpdatedAt()->getTimestamp());
}
```

The ways to opt in:

**1. Implement `CacheableViewInterface`** — the view declares its own signature and every controller returning it gets caching automatically:

```
final class UserView extends BindView implements CacheableViewInterface
{
    public ?int $id = null;
    public ?string $name = null;

    public function __construct(private readonly User $user)
    {
        parent::__construct($user);
    }

    public function getCacheSignature(): string
    {
        return sprintf('user_%d_%d', $this->user->getId(), $this->user->getUpdatedAt()->getTimestamp());
    }
}
```

On a repeated request the serialization (normalize + `json_encode`) is skipped and the JSON comes from the pool. The view object itself is still constructed by the controller.

If there is no convenient entity marker to build a signature from, `AutoCacheSignatureTrait` derives it automatically from the view's values (a hash of the class name + property values):

```
final class UserView extends BindView implements CacheableViewInterface
{
    use AutoCacheSignatureTrait;

    public ?int $id = null;
    public ?string $name = null;
}
```

Identical values hit the cache; any changed value produces a fresh payload, so stale responses are impossible by construction. Two caveats: the view's values must be deterministic for a given source state (a "now" timestamp or random value makes every signature unique and turns the cache into pure overhead), and since the values must exist before they can be hashed, the view is fully built on every request — only the serialization step is saved (~15-20% of the request pipeline for a 100-item collection; see `CachedViewBench`). When the source data offers an id + modification marker, `CachedBindView` or `CachedView` save much more.

**2. Extend `CachedBindView`** — the recommended option for `BindView`-based views. Binding is deferred until the view is actually serialized, and the signature comes from `createCacheSignature()`, so a cache hit skips both binding and serialization while the controller keeps returning the view directly:

```
final class UserView extends CachedBindView
{
    public ?int $id = null;
    public ?string $name = null;

    public static function createCacheSignature(object $source): string
    {
        \assert($source instanceof User);

        return sprintf('user_%d_%d', $source->getId(), $source->getUpdatedAt()->getTimestamp());
    }
}

// controller — unchanged BindView ergonomics
return new UserView($user);
```

`ViewNormalizer` triggers the deferred binding on a cache miss (or whenever the view is serialized outside the cache flow, including nested in other views), so an unbound view can never leak empty payloads.

**3. Return a `CachedView`** — a descriptor pairing a source object with the view class that renders it. On a hit nothing is built; on a miss the view is created via `new UserView($user)` (or an explicit factory) when the serializer asks for it:

```
public function show(User $user): CachedView
{
    return new CachedView($user, UserView::class);          // UserView implements SourceCacheSignatureInterface
    // or with an explicit factory / TTL:
    return new CachedView($user, UserView::class, fn (): ViewInterface => new UserView($user), ttl: 3600);
}
```

`CachedView` carries no HTTP status or headers — it always renders in the standard `DataView` envelope with a 200 response. Use `ResponseView`/`DataView` directly when a response needs custom status or headers.

**4. Return a `PrivateCachedView`** — for private payloads: responses that vary per viewing context, in the spirit of `Cache-Control: private`. The current security user's identity **and** the current request locale become part of the signature, so every user gets an isolated cache entry per locale and can never be served another user's or another locale's payload. Neither is passed around — views become context-aware through static bridges, the same pattern `BindView` uses for `BindUtils`:

```
final class ArticleView extends View implements SourceCacheSignatureInterface
{
    use LocalisationAwareTrait;
    use SecurityAwareTrait;

    public int $id;
    public string $title;
    public bool $canEdit;

    public function __construct(Article $article)
    {
        $this->id = $article->getId();
        $this->title = $article->getTitle(self::getLocale() ?? 'en');
        $this->canEdit = self::getUserIdentifier() === $article->getAuthorIdentifier();
    }

    public static function createCacheSignature(object $source): string { /* id + updated-at */ }
}

// controller
public function show(Article $article): PrivateCachedView
{
    return new PrivateCachedView($article, ArticleView::class);
}
```

Under the hood, `SetSecuritySubscriber` injects the token storage into `SecurityBridge` and `SetLocalisationSubscriber` injects the request stack into `LocalisationBridge` on every request (mirroring `SetVersionSubscriber`/`BindUtils`); `SecurityAwareTrait` / `LocalisationAwareTrait` expose them to views as `self::getUser()`, `self::getUserIdentifier()` and `self::getLocale()`. Without `symfony/security` (or before authentication) the user resolves to a single shared `anonymous` entry; without a request (CLI) the locale resolves to a `default` entry. Private entries multiply per user × locale × entity; they expire after the configured `default_ttl` (one day by default), and a shorter per-descriptor TTL is worth setting for high-cardinality endpoints. `PrivateCachedView` also works as a collection entry, exactly like `CachedView`. For custom scoping (only user, only locale, tenant id, …), put the relevant aware-traits directly into the view's `createCacheSignature()` and return a plain `CachedView`.

The bridges are safe under long-running runtimes (FrankenPHP worker mode, RoadRunner, Swoole): they hold *services* (token storage, request stack), never a resolved user or locale, and resolve at call time. Symfony resets the token storage and pops the request stack between worker requests, so nothing can leak into the next request even though the static references survive.

**5. Cache collection items with `#[Type(..., cached: true)]`** — inside an `IterableView`-typed property, each element becomes a `CachedView` descriptor and its normalized payload is cached per item:

```
final class UserListView extends BindView
{
    #[Type(UserView::class, cached: true)]  // UserView implements SourceCacheSignatureInterface
    public ?IterableView $users = null;
}
```

Unchanged items are served from the per-item cache and only new or modified entities are bound and normalized — a page mixing cached and fresh items pays only for the fresh ones. The final `json_encode` over the assembled payload still runs per request. For personalised collection items, map elements to `PrivateCachedView` explicitly:

```
new IterableView($articles, fn (object $a): PrivateCachedView => new PrivateCachedView($a, ArticleView::class));
```

Setting `response_cache.enabled: false` (e.g. in `config/packages/dev/`) turns all response caching into a transparent pass-through — everything is built and serialized on every request.

Whether caching pays off depends on the pool and the payload: a hit costs a pool lookup, flat in payload size, so cache large or expensive payloads — a tiny 5-property payload rebuilds faster than any out-of-process pool can answer. See the [benchmark results](#results) below for the full comparison across pools and caching modes.

Architecture
------------

[](#architecture)

### Request/Response Flow

[](#requestresponse-flow)

1. **SetVersionSubscriber** (priority 256) — injects the DI-managed `BindUtils` instance into `BindView` via `BindView::setBindUtils()`
2. Controller returns a `ViewInterface` object
3. **ViewSubscriber** — detects `ViewInterface` results, wraps non-`ResponseViewInterface` in `DataView`, serializes to JSON via `ViewNormalizer`. `CachedView` results are resolved through `ViewResponseCache` first; on a hit the cached JSON is returned without building or serializing the view

### View Auto-Discovery

[](#view-auto-discovery)

Views implementing `ViewInterface` are automatically tagged with `chamber_orchestra.view` via `#[AutoconfigureTag]`. The `ViewPass` compiler pass collects these classes and passes them to cache warmers for pre-computation.

Performance Optimizations
-------------------------

[](#performance-optimizations)

The bundle includes a two-phase optimization strategy for production environments:

### Phase 1: Runtime Metadata Caching

[](#phase-1-runtime-metadata-caching)

- `ViewMetadataFactory` caches property metadata in memory
- `BindUtils` binds through `ReflectionProperty` objects and cached getter resolution instead of the PropertyAccessor machinery (~3.5× faster property binding)
- Doctrine proxies are initialized once per sync, not per property access
- 30-50% faster normalization on repeated calls

### Phase 2: Build-Time Cache Warming

[](#phase-2-build-time-cache-warming)

- `ViewMetadataCacheWarmer` pre-computes view property metadata at build time
- `BindUtilsCacheWarmer` pre-computes property mappings (uses `#[BindsFrom]` for targeted source classes, falls back to N² pairs)
- Generated opcache-optimized PHP files stored in `kernel.share_dir`
- Cache files are versioned with `container.build_id` for safe deployments
- 60-80% reduction in reflection overhead on production requests
- Automatic fallback to reflection when warmed cache is unavailable

### Cache Configuration

[](#cache-configuration)

`BindUtils` is registered as a DI service with `$buildId`, `$debug`, and `$shareDir` constructor arguments. When `APP_DEBUG=false`, property accessor caching is enabled with a 24-hour lifetime. `SetVersionSubscriber` injects the configured instance into `BindView` on each request.

**Warm the cache in production:**

```
bin/console cache:warmup --env=prod
```

This generates build-versioned files in the shared cache directory:

- View property metadata (nullability, defaults, types)
- View-to-view property mappings for `BindUtils`

### Response-Level JSON Caching

[](#response-level-json-caching)

Signature-based caching (see above) short-circuits the pipeline for payloads whose state is identifiable: `CacheableViewInterface` skips normalization and `json_encode`; `CachedBindView` and `CachedView` additionally skip property binding / view construction. `ViewResponseCache` stores the final JSON string in the configured PSR-6 pool (`response_cache.pool`, default `cache.app`); when disabled or without a pool it degrades to a transparent pass-through.

Benchmarks
----------

[](#benchmarks)

PHPBench benchmarks are included to measure serialization performance and cache impact:

```
composer bench                               # Run all benchmarks
vendor/bin/phpbench run --report=default     # Run with default report
```

Benchmark classes: `BindUtilsBench`, `CacheWarmupBench`, `NormalizationBench`, `CachedViewBench`.

An optional Redis benchmark (`CachedViewRedisProfile`) is excluded from the default run and requires a reachable Redis server (`redis://127.0.0.1:6379` by default, override via `CACHED_VIEW_BENCH_REDIS_DSN`):

```
vendor/bin/phpbench run benchmark/CachedViewRedisProfile.php --report=aggregate
```

### Results

[](#results)

Reference numbers from an Apple Silicon workstation (PHP 8.5 CLI, `--php-disable-ini`, so no opcache/xdebug — treat them as relative comparisons, not absolutes).

**Property binding** (`BindUtilsBench`, 5-property `BindView` per construction):

ScenarioTimeBind from public-property source4.3μsBind from private properties + getters (Doctrine style)4.4μsBind from private properties without getters4.8μsAll target properties already populated (skip path)1.4μs**Normalization** (`NormalizationBench`):

ScenarioTimeNormalize 3-property view0.7μsNormalize 10-property view1.8μsSerialize (normalize + `json_encode`) 3-property view1.3μsSerialize 10-property view2.5μs**Response caching** (`CachedViewBench` / `CachedViewRedisProfile`) — small payload is a 5-property view, large is a 100-item collection bound via `#[Type]`:

ScenarioSmall payload100-item collectionFull pipeline, no cache6.8μs544μs`CachedView` / `CachedBindView` hit, in-memory pool0.8μs0.6μs`PrivateCachedView` hit (user + locale scoped), in-memory pool1.1μs1.2μs`CachedView` / `CachedBindView` hit, filesystem pool16.4μs17.3μs`PrivateCachedView` hit, filesystem pool17.0μs18.4μs`CachedView` hit, Redis pool (loopback)41.7μs43.1μsPer-item cache only (`#[Type(cached: true)]`), in-memory pool—116μs`AutoCacheSignatureTrait` hit (view still built), in-memory pool—452μsTakeaways: a cache hit costs the pool lookup, flat in payload size, so caching wins once building + serializing exceeds it — dramatically for collections (~900× in-memory, ~30× filesystem, ~13× Redis on loopback), while a tiny payload rebuilds faster (6.8μs) than any out-of-process pool answers. `PrivateCachedView` adds ~0.3μs over `CachedView` for resolving the user and locale from the bridges. Per-item caching keeps item-level invalidation at a middle-ground cost, and value-hash signatures (`AutoCacheSignatureTrait`) only save the serialization slice since the view must be built to be hashed.

Development
-----------

[](#development)

```
composer install          # Install dependencies
composer test             # Run all tests (172 tests, 543 assertions)
./bin/phpunit             # Run tests directly
./bin/phpunit --filter X  # Run specific test class or method
```

License
-------

[](#license)

MIT

###  Health Score

61

—

FairBetter than 99% of packages

Maintenance91

Actively maintained with recent releases

Popularity43

Moderate usage in the ecosystem

Community24

Small or concentrated contributor base

Maturity73

Established project with proven stability

 Bus Factor1

Top contributor holds 90.9% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~26 days

Recently: every ~33 days

Total

34

Last Release

43d ago

Major Versions

v7.3.0 → v8.0.12025-12-29

PHP version history (3 changes)v7.0.1PHP ^8.3

v7.2.0PHP ^8.4

v8.0.10PHP ^8.5

### Community

Maintainers

![](https://www.gravatar.com/avatar/44037eb1c8dc2c4fa9871ac213653f33e22a9348dcec7132df07cc71933f2a2e?d=identicon)[wtorsi](/maintainers/wtorsi)

---

Top Contributors

[![wtorsi](https://avatars.githubusercontent.com/u/2115840?v=4)](https://github.com/wtorsi "wtorsi (40 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (4 commits)")

---

Tags

doctrinejsonjson-apiphpphp8property-bindingrest-apiserializationsymfonysymfony-bundleview-modeljsonsymfonyserializationviewREST APIJSON-APISymfony Bundleview-modelapi-responseproperty-binding

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/chamber-orchestra-view-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/chamber-orchestra-view-bundle/health.svg)](https://phpackages.com/packages/chamber-orchestra-view-bundle)
```

###  Alternatives

[2lenet/crudit-bundle

The easy like Crud'it Bundle.

1617.3k16](/packages/2lenet-crudit-bundle)[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k18.3M430](/packages/easycorp-easyadmin-bundle)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

595.8M674](/packages/shopware-core)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k6.0M778](/packages/sylius-sylius)[shopware/platform

The Shopware e-commerce core

3.4k1.5M3](/packages/shopware-platform)[chameleon-system/chameleon-base

The Chameleon System core.

1029.4k6](/packages/chameleon-system-chameleon-base)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
