Foreign Key Constraints

ObjectQuel can declare real, database-enforced foreign key constraints alongside your entity definitions. @Orm\ForeignKey and @Orm\ForeignKeyAction are purely a schema/DDL concern — they generate FOREIGN KEY constraints through migrations, and are completely independent of the relationship annotations (@ManyToOne and @OneToOne) that drive object hydration and via joins in the query language.

explanation

Foreign Keys vs. Relationship Annotations

ObjectQuel already lets you model relationships between entities with @ManyToOne and @OneToOne (see Relationship Mapping). Those annotations are an ORM-level concern: they tell ObjectQuel how to join entities in ObjectQuel queries and how to hydrate related objects. On their own, they impose no referential integrity at the database level — nothing stops a row from pointing at a customer that no longer exists.

@Orm\ForeignKey and @Orm\ForeignKeyAction close that gap. They declare an actual FOREIGN KEY ... REFERENCES ... constraint, enforced by the database engine itself. They are structural/DDL annotations only:

  • A real FK constraint is added to a plain scalar column (e.g. $customerId) — no object relationship needs to exist for it at all.
  • They have no effect on ObjectQuel's via clause or join inference — that is still driven entirely by @ManyToOne / @OneToOne metadata.
  • @Orm\ForeignKey only has meaning on a plain @Orm\Column-backed scalar property. Declared on a @ManyToOne or @OneToOne relationship property instead, it is simply ignored — no constraint is generated and metadata building is unaffected.

@Orm\ForeignKey

Declares the constraint's target — which table and column this column references:

class OrderEntity {
    /**
     * @Orm\Column(name="customer_id", type="integer")
     * @Orm\ForeignKey(target=CustomerEntity::class, referencedColumn="id")
     */
    protected ?int $customerId = null;
}
Parameter Required Description
target Yes The referenced entity, as a ::class reference (e.g. CustomerEntity::class)
referencedColumn No The column on the target entity this key points to. Defaults to the target's primary key

@Orm\ForeignKeyAction

Optional companion to @Orm\ForeignKey on the same property. Declares the constraint's ON DELETE / ON UPDATE behavior. A bare @Orm\ForeignKey with no @Orm\ForeignKeyAction gets the safe defaults:

class OrderEntity {
    /**
     * @Orm\Column(name="customer_id", type="integer")
     * @Orm\ForeignKey(target=CustomerEntity::class, referencedColumn="id")
     * @Orm\ForeignKeyAction(onDelete="CASCADE", onUpdate="RESTRICT")
     */
    protected ?int $customerId = null;
}
Parameter Default Description
onDelete RESTRICT Action when the referenced row is deleted
onUpdate NO ACTION Action when the referenced key value is updated

Valid values for both parameters:

Value Behavior
RESTRICT Blocks the delete/update if a referencing row exists
CASCADE Propagates the delete/update to referencing rows
SET NULL Sets the referencing column to NULL (the column must be nullable)
NO ACTION Database-specific; on most engines behaves like RESTRICT but the check can be deferred
@Orm\ForeignKeyAction requires a @Orm\ForeignKey on the same property — it configures that constraint's behavior and means nothing on its own. Declaring it without a matching ForeignKey raises an error when entity metadata is built.

Foreign Keys and Cascade Are Independent

@Orm\Cascade (see Cascading) and @Orm\ForeignKey / @Orm\ForeignKeyAction solve related but distinct problems, and are deliberately unrelated in ObjectQuel:

@Orm\Cascade @Orm\ForeignKey / @Orm\ForeignKeyAction
Enforced by ObjectQuel, in PHP The database engine
Requires A @ManyToOne/@OneToOne on the same property Only a plain @Orm\Column-backed scalar property
Fires lifecycle callbacks Yes (@PreDelete, @PostDelete, etc.) No — the database never invokes PHP code
Visible in query logs Yes — one query per cascaded row No — happens inside the single DELETE/UPDATE statement

You can use either alone, both together, or neither. Using both is common: @Orm\Cascade(operations={"remove"}) gives you visibility and lifecycle hooks when the entity manager performs the delete, while a matching @Orm\ForeignKeyAction(onDelete="CASCADE") ensures rows stay consistent even for deletes issued outside ObjectQuel (raw SQL, another application, a manual database change).

Generating Constraints on New Entities

Foreign key generation is gated behind the generate_foreign_keys option in config/database.php, off by default:

// config/database.php
return [
    // ...
    'generate_foreign_keys' => false,
];

When enabled, make:entity's interactive relationship prompt automatically attaches @Orm\ForeignKey to the generated foreign key column whenever you create an owning-side ManyToOne or OneToOne relationship — no extra prompt for ON DELETE/ON UPDATE is shown. The constraint is written with the safe defaults (RESTRICT / NO ACTION); edit the generated @Orm\ForeignKeyAction by hand afterward if the relation needs different behavior.

Detecting Existing Constraints

With generate_foreign_keys enabled, reverse engineering an entity from an existing table (see Schema Management) also reads real foreign key constraints already present in the database and emits matching annotations:

$ php bin/sculpt make:entity-from-table

 Select table: > orders
 Success! Created: src/Entity/OrderEntity.php
 Detected: FOREIGN KEY customer_id -> customers.id (ON DELETE CASCADE)

The detected constraint is emitted on the plain scalar column — it does not turn the column into a ManyToOne/OneToOne relation:

/**
 * @Orm\Column(name="customer_id", type="integer")
 * @Orm\ForeignKey(target=CustomerEntity::class, referencedColumn="id")
 * @Orm\ForeignKeyAction(onDelete="CASCADE", onUpdate="RESTRICT")
 */
protected ?int $customerId = null;

@Orm\ForeignKeyAction is only emitted when the live constraint's rule deviates from the defaults (RESTRICT / NO ACTION) — its absence means the defaults apply, the same convention used elsewhere for optional annotations.

Composite (multi-column) foreign keys are skipped during detection — @Orm\ForeignKey only supports a single referencedColumn and can't represent them.

Migrations: make:migrations

make:migrations diffs each entity's declared @Orm\ForeignKey/@Orm\ForeignKeyAction annotations against the live database schema, alongside the usual column and index diffing, and adds the necessary constraint changes to the generated migration. See Schema Management for the full command reference.

ObjectQuel's SQLite connections run PRAGMA foreign_keys = ON automatically, so constraints declared via @Orm\ForeignKey are enforced — SQLite ignores foreign keys by default unless this pragma is set on every connection.

Common Pitfalls

1. Putting @Orm\ForeignKey on the Relationship Property

// AVOID - ForeignKey belongs on the scalar column, not the relation.
// This is not an error: the annotation is simply ignored, and no
// constraint is generated for this property.
class OrderEntity {
    /**
     * @Orm\ManyToOne(targetEntity=CustomerEntity::class)
     * @Orm\ForeignKey(target=CustomerEntity::class)  // ignored
     */
    private CustomerEntity $customer;
}

// CORRECT
class OrderEntity {
    /**
     * @Orm\ManyToOne(targetEntity=CustomerEntity::class, localColumn="customerId")
     */
    private CustomerEntity $customer;

    /**
     * @Orm\Column(name="customer_id", type="integer")
     * @Orm\ForeignKey(target=CustomerEntity::class)
     */
    private int $customerId;
}

2. @Orm\ForeignKeyAction Without a Matching @Orm\ForeignKey

// WRONG - nothing to configure the action for
class OrderEntity {
    /**
     * @Orm\Column(name="customer_id", type="integer")
     * @Orm\ForeignKeyAction(onDelete="CASCADE")  // error - no ForeignKey on this property
     */
    private int $customerId;
}