somnambulist/read-models-bundle
A Laravel package for building CQRS-style read models and projections, offering base classes and Eloquent-backed query models to create denormalized, read-optimized views from domain events and write-side data.
Install and register
composer require somnambulist/read-models-bundle
Register the bundle the normal Symfony way (config/bundles.php on 5.4+, or AppKernel/bundles.php on older versions). The bundle name will resolve to Somnambulist\Bundles\ReadModelsBundle\ReadModelsBundle — confirm the namespace by looking at the *Bundle.php file in vendor/somnambulist/read-models-bundle/src/.
Where to look first
Before writing any code, open these three files in the vendor directory:
src/DependencyInjection/Configuration.php — this is the authoritative list of config keys, defaults, and whether sections are required. It is more reliable than any README.src/DependencyInjection/*Extension.php — shows what services get registered (DBAL connections, the read-model registry, tagged services, compiler passes).composer.json — confirms which somnambulist/read-models major version you are pinned to, since the underlying API changes between majors.Because this package has ~1 star and no dependents, assume documentation is thin and treat the source as the spec. Don't trust blog posts.
Minimal config
The bundle exists to wire one or more Doctrine DBAL connections into the read-model layer. A minimal config/packages/read_models.yaml will look roughly like:
read_models:
connections:
default:
# dbal connection service id or connection params
connection: doctrine.dbal.default_connection
The exact key names come from Configuration.php; copy the tree from there. If there is a dump command, bin/console config:dump-reference <alias> gives you the full tree instantly.
First use case
The fastest end-to-end win: build a denormalized "read" table (e.g. order_summary) with raw SQL/DBAL migrations, then expose it through a read model:
use Somnambulist\Components\ReadModels\ReadModel;
final class OrderSummary extends ReadModel
{
protected ?string $table = 'order_summary';
protected ?string $alias = 'os';
protected array $casts = [
'id' => 'uuid',
'total_cents' => 'integer',
'created_at' => 'datetime',
];
}
Then inject it (or the registry) into a controller and call OrderSummary::query()->...->fetch(). If autowiring picks up the read-model services, this works with zero extra wiring; if not, tag or explicitly define the service.
Read models as plain services
Treat each read model as a small, immutable query object. Keep them free of business logic — they should only express how to fetch a shape of data. Put projections/updaters elsewhere. This keeps them trivially testable against a real (or in-memory SQLite) DBAL connection.
CQRS flow that fits this bundle
The natural integration is: write model (Doctrine ORM or event sourcing) → domain events → projector/listener → INSERT/UPDATE into the read table → read model queries it directly. The bundle does not provide the projector; you write a Symfony event subscriber or Messenger handler that does the writes, and the read model only ever does SELECT. That separation is the whole point — reads never touch the write-side ORM.
#[AsMessageHandler]
final class ProjectOrderPlaced
{
public function __construct(private Connection $dbal) {}
public function __invoke(OrderPlaced $event): void
{
$this->dbal->insert('order_summary', [
'id' => $event->orderId->toString(),
'total_cents' => $event->total->getMinorAmount(),
'created_at' => $event->occurredAt->format('Y-m-d H:i:s'),
]);
}
}
Multiple connections / read replicas
Use the bundle config to declare a second (read-only replica) connection and point heavy read models at it. The underlying read-models library resolves connections by name, so a read model can declare which connection it uses. This is one of the main reasons to use the bundle rather than bootstrapping read-models by hand — connection registration is centralized.
Constructor injection vs static queries
Prefer injecting the model or the registry over calling static ReadModel::query() in deep code. Static calls hide the DB dependency and make tests need global state. In controllers, however, the static style is idiomatic for read-models and acceptable; just don't spread it through your domain layer.
Eager loading and shape control
Use the library's relationship/eager-load methods (e.g. ->include(...), ->with(...) — check the version's API) rather than lazy-loading per row. Read models should be tuned for the exact endpoint that consumes them. If a table serves two very different screens, write two read models against the same table with different select/projection settings — that is cheaper than conditional query building.
Testing
Point a read model at an SQLite DBAL connection in a unit test, run your migrations, insert fixtures, and assert on the returned objects. Because read models are read-only, most tests are arrange → insert → query → assert, with no transaction/identity-map surprises.
Console/rebuild tooling
If the bundle registers build/rebuild or projection commands (check src/Console), wire them into your deploy pipeline: bin/console <read-model-build> after migrations, before traffic. If it does not, write a small Symfony command that truncates and re-projects from the event store — you will want it.
somnambulist/read-models has changed its static configuration between majors (connection, alias, table, casting). Pin the version in composer.json and read the changelog of the library, not just the bundle, when upgrading.read_models vs somnambulist_read_models) is defined in the Extension. If Symfony says "unrecognized option," you guessed wrong — run config:dump-reference or read the Extension's getAlias().read_models.yaml, bin/console cache:clear in dev before concluding your key is wrong. It is almost never the bundle's fault.datetime cast returns the library's datetime object, not \DateTimeImmutable. If you serialize to JSON, verify the shape — you may need an explicit transformer.vendor/somnambulist/read-models/src. If that's unacceptable for your team, skip the bundle and use plain DBAL — for a single app, the bundle mostly saves you ~50 lines of connection wiring.addCompilerPass in the bundle's *Bundle.php, and use #[AutoconfigureTag]/service tags on your own models if a registry needs to discover them (e.g. for a rebuild command).Bottom line for assessment: this is a thin, small-bundle integration layer over a capable read-model library. It is worth using only if you are already committing to somnambulist/read-models; otherwise, the day-to-day cost of sparse docs and no community outweighs the saved wiring. If you do adopt it, read Configuration.php first, pin versions, and own your projectors and migrations explicitly.
How can I help you explore Laravel packages today?