darangonaut/laravel-doctrine-projections
A Laravel package for Doctrine projections that helps you build and maintain read models from domain events. It integrates projection classes with Laravel’s container and console, making event-sourced read-model setup and rebuilding straightforward.
This package generates Eloquent "projection" models from your Doctrine ORM mappings, so reads can go through Eloquent (chunk, cursor, lazy, paginate, route model binding, replicate, toArray, toJson) while writes stay in Doctrine. Read the UPGRADING.md first — 0.7.0 is a minor with three hard refusals, not a patch — then the generated-output docs.
Minimal path:
paginate(), a report with cursor(), an API resource with toJson().Your first real use case is almost always replacing a hand-written Doctrine DQL query on a listing page with Projection::query()->paginate(). Do that one screen, confirm the generated models survive Model::shouldBeStrict() in dev, then widen.
Read/write split by construction. Doctrine owns the entity and the unit of work; projections own the query surface. Keep that boundary literal — do not add mutators, scopes, or relationships to generated models, because regeneration will eat them. If you need custom query logic, put it in a repository or query object that takes the projection class as a parameter rather than subclassing the generated model.
Treat generated output as a build artifact with a drift check. Because generation is DB-free and sub-second, the strongest pattern is: regenerate in CI and git diff --exit-code on the output directory. That turns "someone changed a mapping and forgot to regenerate" into a red build instead of a runtime Column not found.
Migration generation is a separate, deliberate step. The migration name is timestamp-to-the-second + --name, defaulting to doctrine_diff. Two ordinary runs in the same second used to resolve to one path; 0.7.0 refuses to overwrite instead. So: never generate migrations in a loop or in CI, and always pass an explicit --name when you generate them (e.g. per-feature-branch), so parallel work doesn't collide on the default.
Single-table inheritance: pick the right entry point. The root class stays unscoped — "every node" is a legitimate question — and everything below it is scoped to its own discriminator value plus its subclasses'. So:
| Intent | Query |
|---|---|
| Every row in the table, all types | root projection |
| One concrete type | that subclass |
| A mid-hierarchy abstract type and its children | that abstract class (now correctly scoped) |
Prefer querying the most specific class that expresses the intent; reaching for the root when you meant one type is the most common way to leak rows into a listing.
Regeneration workflow per mapping change: change the Doctrine mapping → regenerate → drift check passes → if the schema moved, generate a migration with an explicit name → review the migration by hand (it's a diff, not a design). Projections follow mappings, migrations follow schema; they are not the same trigger.
Strict mode in dev. The verified Eloquent surface — chunk, cursor, lazy, paginate, route model binding, replicate, toArray, toJson — is tested under Model::shouldBeStrict(). Enable it locally so N+1s and missing-attribute access surface before production.
Audit aggregate numbers after upgrading to 0.7.0. The silent fix: a mid-hierarchy abstract class used to have no scope and returned every row in the table (AbstractFile::query()->count() → 4, including a folder; findAll() → 3). It is now scoped. Any dashboard, export, or count that queried an abstract subclass will drop after upgrade. Confirm the new number is the one you meant, and switch to the root projection only where "every node" is genuinely the question.
Generation failure was silent; it no longer is — but check your exit codes anyway. File::put() returns false with Laravel's error handler off and throws a raw ErrorException with it on. Before 0.7.0 a run that wrote nothing still printed "3 projection(s) generated" and exited 0. Your deploy scripts should now assert on exit status and on the drift check; a green pipeline that produced no files is exactly the failure this release closed.
Three new refusals will stop previously "working" runs:
Order.php / order.php) — one file on macOS and Windows. Rename one; this is a portability bug, not a package quirk.--name, and don't regenerate in the same second.Root is unscoped, deliberately. Root::query() includes every subclass row. That's correct behaviour, not a leak — but it means a root query with an ad-hoc where type is a smell; you probably wanted the subclass.
Three mappings are documented as broken, not guarded: a non-backed enum, enumType on a json column, and a discriminator column mapped as a field. Fix the mapping; the package will not catch these for you.
Pre-1.0 discipline. Every 0.x minor can refuse something that used to run. Read UPGRADING.md on every bump, pin exact versions in composer.json, and keep the regenerate-and-diff check so a bump that changes generated output shows up as a reviewable diff rather than a production surprise.
Debugging order when a projection misbehaves: (1) is the generated file stale — regenerate and diff; (2) is the class scoped as you expect — check where it sits in the hierarchy; (3) is the underlying mapping one of the three known-broken shapes; (4) only then look at the package. PHPStan level max with no baseline is a good signal that a static-analysis pass on your own projection-consuming code will catch most attribute and typing mistakes.
How can I help you explore Laravel packages today?