Skip to Content
BackendAPICommonbundleCharge Entity

Charge Entity

PHPReaction\Entity\CommonBundle\Charge — table charges

Goal

The Charge entity is a generic, polymorphic glue entity that records the fact that one entity (the charger) charges another entity (the chargeable).

A charger charges a chargeable.

It replaces the former hard-coded charged ManyToMany relations (e.g. Invoice ↔ Rent, Project ↔ Timesheet, …) with a single, uniform link table. Because both ends are stored as (class, id) pairs rather than real foreign keys, any eligible entity can charge any eligible entity without new schema or mapping. The combinations are effectively open-ended:

Charger (charging side)Chargeable (charged side)Example use case
InvoiceRentAn invoice bills out rents
ProjectTimesheet, Allowance, RentProject refacturation: a project charges time & rents
InvoiceBill Line, Timesheet, Rent, …Invoicing a project’s own charges

A charge can itself become chargeable material: a project charges timesheets and rents; those project charges can then be invoiced, producing a new layer of charges in which the invoice charges the project’s charges. The pattern nests indefinitely, but the principle never changes — a charger charges a chargeable.

⚠️ Not every entity is eligible. The two ends are opt-in through dedicated interfaces and traits. An entity is only a valid charger if it implements ChargingInterface, and only a valid chargeable if it implements Chargeable. See Eligibility below.


Data model

A Charge row holds two polymorphic references plus an optional payload.

Charging side (the charger)

ColumnPropertySerialized asNotes
charging_object_classchargingObjectClassrefTableFQCN / table of the charger (e.g. Invoice)
charging_object_idchargingObjectIdrowIdId of the charger row

Charged side (the chargeable)

ColumnPropertySerialized asNotes
charged_object_classchargedObjectClassrefTableFQCN / table of the chargeable (e.g. Rent)
charged_object_idchargedObjectIdrowIdId of the chargeable row

Per-charge payload

These are captured on the charge itself so the charged amount/quantity is frozen in time, independently of later changes to the source entity.

ColumnPropertyTypeNotes
amountamountdecimal(20,5)Charged amount (nullable)
quantityquantitydecimal(20,5)Charged quantity (nullable)
charged_atchargedAtdatetimeWhen the charge occurred (nullable)

Plus everything inherited from BaseEntity: id, slug, timestampable, soft-deleteable, taggable, etc. The entity’s shortcode is CHG.

Constraints & indexes

  • Unique constraint charged_chargeable_unique on (charging_object_class, charging_object_id, charged_object_class, charged_object_id). This guarantees the same charged object cannot be charged twice by the same charger — e.g. an invoice cannot charge the same timesheet twice.
  • Indexes on both the charging and charged (class, id) pairs to keep the polymorphic look-ups fast.

Behaviors

  • #[Gedmo\Loggable] — changes are versioned; amount, quantity and chargedAt are #[Gedmo\Versioned].
  • #[Gedmo\SoftDeleteable(fieldName: 'deletedAt', hardDelete: false)] — deleting a charge soft-deletes it (the row is kept, deletedAt is stamped).

Eligibility: interfaces & traits

Because the ends are polymorphic, the domain must declare which entities are allowed to participate. This is done with interfaces (the contract) and traits (the shared implementation).

┌─────────────────────┐ │ ChargeRelationTrait │ ← holds the $charges collection │ get/set/add/remove │ (+ facades over it) └──────────┬───────────┘ used by │ used by ┌──────────────────────┴───────────────────────┐ ▼ ▼ ┌───────────────────┐ ┌────────────────────┐ │ ChargingTrait │ │ ChargeableTrait │ │ (charger side) │ │ (chargeable side) │ └─────────┬──────────┘ └─────────┬──────────┘ │ implements │ implements ▼ ▼ ┌───────────────────┐ ┌────────────────────┐ │ ChargingInterface │ │ Chargeable │ └───────────────────┘ └────────────────────┘

Charger side — ChargingInterface + ChargingTrait

An entity that can charge others implements PHPReaction\Entity\CommonBundle\ChargingInterface and uses ChargingTrait.

ChargingInterface requires the collection accessors: getCharges(), setCharges(), addCharge(), removeCharge().

ChargingTrait adds:

  • getChargesListing() — the API URL listing this charger’s charges (/open-api/v3/charges?chargingObjectClass=…&chargingObjectId=…).
  • chargedObjectExists(Charge $charge) — whether the given charged object is already linked to this charger.
  • Deprecated backward-compat facades over the charge collection: getChargedRents(), getChargedTimesheets(), getChargedAllowances(), getChargedBillLines().

Implementing entities (chargers): Invoice, BaseProject (Project).

Chargeable side — Chargeable + ChargeableTrait

An entity that can be charged implements PHPReaction\Entity\CommonBundle\Chargeable and uses ChargeableTrait.

Chargeable requires, in addition to the collection accessors:

  • isChargeable(): bool — is this instance chargeable at all?
  • isValidChargeable() — is it in a state that may currently be charged? (e.g. Rent::isValidChargeable() returns true only once the rent is returned).
  • getChargeablePrice() — the price to charge.
  • getChargeableQty() — the quantity to charge.
  • getChargeableGroup() — the grouping key used when aggregating charges.

ChargeableTrait adds:

  • A mapped chargeable boolean column (default true) with get/is/set, so an instance can be flagged non-chargeable.
  • getChargesListing() — the API URL listing charges targeting this object (/open-api/v3/charges?chargedObjectClass=…&chargedObjectId=…).
  • chargingObjectExists(Charge $charge) — whether a given charger already charges this object.
  • Deprecated backward-compat facades: getChargingInvoices(), getChargingProjects().

Implementing entities (chargeables): BaseRent (Rent), BaseTimesheet (Timesheet), Allowance, Line (Bill Line).

Shared collection — ChargeRelationTrait

Both traits pull in ChargeRelationTrait, which owns the $charges collection and its get/set/add/remove methods.

Important: $charges is intentionally not a mapped Doctrine association and is left untyped — typing it as Collection would make Doctrine try to load it as a mapped relation. The collection is instead populated at runtime by the ChargeListener.


Lifecycle — ChargeListener

PHPReaction\EventListener\CommonBundle\ChargeListener (a Doctrine listener) wires the polymorphic collection to the entities.

  • postLoad — whenever an entity is loaded, if it is a ChargingInterface or a Chargeable, the listener queries the Charge repository by the matching polymorphic keys and injects the results via setCharges(). This is what makes $entity->getCharges() work even though there is no ORM mapping.
  • preSoftDelete — before a charger or chargeable is soft-deleted, its charges are soft-deleted too (each charge’s deletedAt is stamped and an extra update is scheduled in the unit of work), so links never dangle.

Resolving the other end — ChargeManager

PHPReaction\Manager\CommonBundle\ChargeManager turns charges back into the real domain entities they point at (the repository only returns Charge rows). It resolves one polymorphic side through a subquery, in a single query.

  • getChargedObjects(string $chargedClass, array $criteria, ?int $limit, string $direction) — hydrate the charged entities (Rent, Timesheet, Bill Line, …) matching the criteria.
  • getChargingObjects(string $chargingClass, array $criteria, ?int $limit, string $direction) — hydrate the charging entities (Invoice, Project, …).

$criteria is a generic map of Charge field => value equality filters, so any combination of charging/charged columns (or payload fields) can be passed.

// Rents charged by a given invoice (max 30, most recent first) $rents = $chargeManager->getChargedObjects(Rent::class, [ 'chargingObjectClass' => Invoice::class, 'chargingObjectId' => $invoice->getId(), 'chargedObjectClass' => Rent::class, ], 30); // Invoices charging a given rent $invoices = $chargeManager->getChargingObjects(Invoice::class, [ 'chargedObjectClass' => Rent::class, 'chargedObjectId' => $rent->getId(), 'chargingObjectClass' => Invoice::class, ]);

API

Exposed via API Platform as the charges resource. Every operation is role-gated:

OperationRoleSerialization groups
GetCollectionROLE_MOD_CHARGE_LISTINGid, timestampable, chargeListing
GetROLE_MOD_CHARGE_SHOWid, timestampable, chargeShow
PostROLE_MOD_CHARGE_ADDid, chargeShow
PutROLE_MOD_CHARGE_EDITid, chargeShow
DeleteROLE_MOD_CHARGE_DELETE

Filterable (exact SearchFilter) on: id, chargingObjectClass, chargingObjectId, chargedObjectClass, chargedObjectId. These are the filters used by the getChargesListing() URLs the traits generate.

Serialization: each end keeps its own property name

The rest of the codebase serializes a single polymorphic reference under the shared refTable / rowId names (Note, EntityFile, Approval, …). Charge cannot reuse that convention, because it carries two polymorphic references on the same entity — the charging side and the charged side. Aliasing both to refTable / rowId would produce duplicate keys in the same serialized object.

To avoid that conflict, each property is serialized under its own name:

Property#[JMS\SerializedName]
chargingObjectClasschargingObjectClass
chargingObjectIdchargingObjectId
chargedObjectClasschargedObjectClass
chargedObjectIdchargedObjectId

So both ends are unambiguously addressable, and the API Platform / Symfony normalization (chargeShow / chargeListing groups) already exposes the same four getter-derived names.


Typical flow

  1. A charger (Invoice, Project, …) charges a chargeable (Rent, Timesheet, Allowance, Bill Line, …). Eligibility is enforced by the interfaces; state is checked with isChargeable() / isValidChargeable().
  2. A Charge row is created linking (chargingObjectClass, chargingObjectId) to (chargedObjectClass, chargedObjectId), capturing amount / quantity / chargedAt as a snapshot. The unique constraint prevents duplicates.
  3. On load, ChargeListener re-attaches the $charges collection to both ends.
  4. Consumers navigate the graph either through the (deprecated) collection facades or, preferably, through ChargeManager to hydrate the real entities.
  5. Deleting either end soft-deletes its charges; charge rows are never hard-deleted.
Last updated on