Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Formula Doctrine Laravel Package

cryonighter/formula-doctrine

Hibernate-style #[Formula] computed fields for Doctrine ORM 3 entities. Adds read-only, SQL-computed properties populated via subqueries, aggregations, and joins, without N+1 queries. Works standalone or with a Symfony bundle.

View on GitHub
Deep Wiki
Context7

Getting Started

The package adds Hibernate-style computed (read-only) properties to Doctrine 3 entities. There is no Laravel/Eloquent integration — it is pure Doctrine ORM, so day-to-day usage looks like Symfony/Bare-PHP work. If you are on Laravel you will need a Doctrine bridge (e.g. doctrine/orm + EntityManager), which is a heavy lift; most Laravel teams will not consider this package.

Install (standalone)

composer require cryonighter/formula-doctrine

If you are in Symfony, prefer cryonighter/formula-doctrine-bundle which auto-wires the listeners and DBAL middleware.

Manual bootstrap — the five things you must wire (in order):

  1. FormulaMetadataRegistry (with FormulaMetadataFactory)
  2. FormulaMiddleware on the DBAL Configuration
  3. FormulaDoctrineConfigurator::configure($ormConfig)
  4. LoadClassMetadataListener on the loadClassMetadata event
  5. PostGenerateSchemaListener on the postGenerateSchema event

The README’s bootstrap block is the reference — copy it verbatim, then swap in your own driver/URL.

First use case — compute a count without N+1

use Cryonighter\FormulaDoctrine\Attribute\Formula;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'customers')]
class Customer
{
    #[ORM\Id, ORM\Column, ORM\GeneratedValue]
    public int $id;

    #[ORM\Column]
    public string $name;

    // DQL: no surrounding parentheses
    #[Formula('SELECT COUNT(o) FROM App\Entity\Order o WHERE o.customer = {this}')]
    public int $orderCount = 0;

    // Native SQL: MUST be enclosed in parentheses
    #[Formula('(SELECT COALESCE(SUM(oi.price),0) FROM order_items oi JOIN orders o ON oi.order_id=o.id WHERE o.customer_id={this}.id)')]
    public float $totalRevenue = 0.0;

    // Nullable — declare ?T and default null
    #[Formula('(SELECT MAX(o.created_at) FROM orders o WHERE o.customer_id={this}.id)')]
    public ?string $lastOrderDate = null;
}

Then query as normal:

$customers = $em->createQuery('SELECT c FROM App\Entity\Customer c')->getResult();
// $customers[0]->orderCount is populated by a single SQL SELECT

If $customers[0]->orderCount stays 0, the middleware or the loadClassMetadata listener is not wired. Start there. $em->getEventManager() and $connection->getConfiguration()->getMiddlewares() are the two places to inspect first.

Implementation Patterns

Pattern 1 — subquery-scoped aggregates on a single root entity

The canonical use is a "count/sum/max of related rows per entity" without a join or N+1. Because the subquery is embedded per-entity in the SELECT, the whole collection is one SQL roundtrip. This is the pattern you will reuse 80% of the time.

Pattern 2 — DQL for anything that maps 1:1 to entity fields

Prefer DQL when possible:

#[Formula('SELECT COUNT(o) FROM App\Entity\Order o WHERE o.customer = {this}')]
public int $orderCount = 0;

DQL is portable across drivers and respects Doctrine mapping. Native SQL is the escape hatch when you need features DQL does not support (window functions, COALESCE, raw joins — the README’s revenue example).

Note the {this} semantics differ between the two:

  • DQL: {this} is the root entity — compare o.customer = {this} (no .id).
  • Native SQL: {this} resolves to the generated alias (e.g. c0_) — compare o.customer_id = {this}.id.

Getting this wrong produces an SQL error at query time, not a metadata error — so it is easy to ship.

Pattern 3 — filter/order/group by the formula without extra work

$em->createQuery('SELECT c FROM App\Entity\Customer c WHERE c.orderCount > :min')
   ->setParameter('min', 5)
   ->getResult();

The walker embeds the subquery once per query, even when the same formula is used in WHERE + ORDER BY + SELECT. So you can build repositories around it exactly as if it were a real column:

$this->createQueryBuilder('c')
    ->orderBy('c.totalRevenue', 'DESC')
    ->setMaxResults(10)
    ->getQuery()
    ->getResult();

findAll(), findBy(), findOneBy() also work — Doctrine hydrates the formula fields transparently via the standard ObjectHydrator.

Pattern 4 — aggregate across the whole result set

$result = $qb->select('SUM(c.orderCount) AS total', 'AVG(c.totalRevenue) AS avg')
             ->from(Customer::class, 'c')
             ->getQuery()->getSingleResult();

Useful for dashboards. MIN/MAX skip NULLs, so a nullable formula behaves correctly when some customers have no orders.

Pattern 5 — CASE WHEN for categorisation or conditional sort

Formula fields work inside CASE WHEN ... THEN ... END in DQL. Use this to bucket entities by computed value without a second query:

ORDER BY CASE WHEN c.orderCount = 0 THEN 1 ELSE 0 END ASC, c.orderCount DESC

Pattern 6 — nullable formulas

If the underlying aggregate can return NULL (MAX, MIN, any left-join-style subquery), declare the property nullable:

#[Formula('(SELECT MAX(o.total) FROM orders o WHERE o.customer_id = {this}.id)')]
public ?float $maxOrderTotal = null;

Declare the type accurately — the package infers nullability from the type hint, and hydrator behaviour follows it.

Symfony vs standalone

  • Symfony: install the bundle, do not wire the middleware yourself. Everything is auto-configured.
  • Standalone: keep the five-step bootstrap in a factory. Do not scatter it.
  • If you have a Laravel app: this is not an Eloquent package. You would need Doctrine ORM (e.g. doctrine/orm with a Laravel integration); assess carefully whether the effort is warranted — for most Laravel codebases, an Eloquent accessor or a dedicated query class is simpler and does not require a new ORM layer.

Gotchas and Tips

1. Parentheses in native SQL are load-bearing. Native SQL expressions must be wrapped in (...); DQL expressions must not. The parser uses this to distinguish the two. There is no exception or warning — you get a broken query.

2. Do not map the property with #[ORM\Column]. The property must be formula-only. The LoadClassMetadataListener registers it as a non-insertable/non-updatable field and PostGenerateSchemaListener strips it from the schema. If you add ORM\Column you will fight the package — sometimes with a phantom column in migrations, sometimes with a hydrator mismatch.

3. Never hardcode the alias or table name in native SQL. Always use {this}. Doctrine generates c0_, t1_, etc., and the aliases change across queries, joins, and Doctrine versions.

4. {this} behaves differently in DQL vs SQL. In DQL you compare to the entity: o.customer = {this}. In native SQL you compare to a column: o.customer_id = {this}.id. This is the single most common source of runtime errors, especially when mixing approaches in one entity.

5. Formula fields are read-only. They will not be written on flush(). If you assign to them in code, Doctrine will not persist the change, and you may confuse yourself about state. Treat them as projections, not data.

6. Schema drift is handled for you at generation time — not migration time. PostGenerateSchemaListener removes the field from SchemaTool output. If you use migrations that diff against the live DB, a formula property will not appear as a column, which is what you want, but make sure your migration generator is aware: some diff workflows compare against ClassMetadata directly and can produce ghost columns. Prefer SchemaTool-based generation, or inspect the diff before committing.

7. Hydration behaviour around associations. Formulas populate on DQL SELECT, find*(), eager associations, and lazy proxies. But proxies that are never initialised do not run the subquery until they load — which is normal — and if you access a formula on a detached entity you will see the default value (0, null), not a freshly computed one. Do not depend on formula values after detach().

8. FormulaMetadataRegistry caches per class per process. Reflection runs once. If you are iterating on a formula in dev and it seems stale, it may be your opcache or long-running worker (queue/Octane-style), not the registry. Restart the worker.

9. Debugging the SQL. The package rewrites DQL into SQL via FormulaSqlWalker. When something goes wrong, log the generated SQL — in Doctrine use $query->getSQL() before getResult(), or the DB query log. The subquery appears verbatim; a syntax error there is usually a misplaced parenthesis or a {this} mis-resolution.

10. Middleware ordering matters. If you already have DBAL middlewares (logging, instrumentation, sharding), register FormulaMiddleware consistently. Put it before logging if you want the rewritten SQL logged. If it runs after, you may log the pre-formula query and conclude the feature is disabled.

11. Custom alias is the extension point for reporting. The alias: argument changes the raw SQL column name only — not the PHP property name. Use it when a BI tool or a downstream reader keys on a specific column name. Do not use it casually, because aliasing hides the natural link between the property and the SQL.

12. Version constraints. PHP ≥ 8.2 and Doctrine ORM 3. Confirm both before adopting. This is a young package (0 stars) — pin a version and watch the changelog; the FormulaMiddleware/walker contract is the most likely place for breaking changes.

13. Performance intuition. The formula embeds a correlated subquery per entity. That is dramatically better than N+1 but can still be slow on large result sets if the subquery is not sargable against your index. If a WHERE clause uses a formula field, PostgreSQL/MySQL will run the subquery for every candidate row. Check with EXPLAIN before shipping a "top N by formula" query to production.

14. No bundle? No problem — but no shortcuts either. Do not try to skip the LoadClassMetadataListener, the FormulaDoctrineConfigurator, or the FormulaMiddleware. Each covers a different phase: metadata, DQL walking, and SQL rewriting. Missing any one leaves you in a half-working state where the property exists but the subquery never runs, or vice versa.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin