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 |
|---|---|---|
Invoice | Rent | An invoice bills out rents |
Project | Timesheet, Allowance, Rent | Project refacturation: a project charges time & rents |
Invoice | Bill 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 implementsChargeable. See Eligibility below.
Data model
A Charge row holds two polymorphic references plus an optional payload.
Charging side (the charger)
| Column | Property | Serialized as | Notes |
|---|---|---|---|
charging_object_class | chargingObjectClass | refTable | FQCN / table of the charger (e.g. Invoice) |
charging_object_id | chargingObjectId | rowId | Id of the charger row |
Charged side (the chargeable)
| Column | Property | Serialized as | Notes |
|---|---|---|---|
charged_object_class | chargedObjectClass | refTable | FQCN / table of the chargeable (e.g. Rent) |
charged_object_id | chargedObjectId | rowId | Id 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.
| Column | Property | Type | Notes |
|---|---|---|---|
amount | amount | decimal(20,5) | Charged amount (nullable) |
quantity | quantity | decimal(20,5) | Charged quantity (nullable) |
charged_at | chargedAt | datetime | When 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_uniqueon(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,quantityandchargedAtare#[Gedmo\Versioned].#[Gedmo\SoftDeleteable(fieldName: 'deletedAt', hardDelete: false)]— deleting a charge soft-deletes it (the row is kept,deletedAtis 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()returnstrueonly 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
chargeableboolean column (defaulttrue) withget/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:
$chargesis intentionally not a mapped Doctrine association and is left untyped — typing it asCollectionwould make Doctrine try to load it as a mapped relation. The collection is instead populated at runtime by theChargeListener.
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 aChargingInterfaceor aChargeable, the listener queries theChargerepository by the matching polymorphic keys and injects the results viasetCharges(). 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’sdeletedAtis 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:
| Operation | Role | Serialization groups |
|---|---|---|
GetCollection | ROLE_MOD_CHARGE_LISTING | id, timestampable, chargeListing |
Get | ROLE_MOD_CHARGE_SHOW | id, timestampable, chargeShow |
Post | ROLE_MOD_CHARGE_ADD | id, chargeShow |
Put | ROLE_MOD_CHARGE_EDIT | id, chargeShow |
Delete | ROLE_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] |
|---|---|
chargingObjectClass | chargingObjectClass |
chargingObjectId | chargingObjectId |
chargedObjectClass | chargedObjectClass |
chargedObjectId | chargedObjectId |
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
- A charger (
Invoice,Project, …) charges a chargeable (Rent,Timesheet,Allowance,Bill Line, …). Eligibility is enforced by the interfaces; state is checked withisChargeable()/isValidChargeable(). - A
Chargerow is created linking(chargingObjectClass, chargingObjectId)to(chargedObjectClass, chargedObjectId), capturingamount/quantity/chargedAtas a snapshot. The unique constraint prevents duplicates. - On load,
ChargeListenerre-attaches the$chargescollection to both ends. - Consumers navigate the graph either through the (deprecated) collection facades
or, preferably, through
ChargeManagerto hydrate the real entities. - Deleting either end soft-deletes its charges; charge rows are never hard-deleted.