Skip to main content

PG.L8.FK_ID_SUFFIX — A foreign key column Laravel cannot derive a relation from

  • Category: convention
  • Level: 8
  • Confidence: deterministic
  • Downtime class: none
  • Stability: stable
  • Suites: lint, audit
  • Applies to: PostgreSQL 18 and newer

What it checks

A foreign key over one column whose name does not end in _id.

Reported:

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author')->constrained('users');
});

Not reported:

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained('users');
});

Why this one is not taste

Level 8 is where conventions live, and most of what belongs there is preference. This one buys something specific: Eloquent derives a relation's foreign key from the related model, so belongsTo(User::class) resolves to user_id with nothing written down. A column that departs from it keeps working — and every relation on that side has to pass the key explicitly, forever, in code nobody re-reads when the schema moves.

That is the cost the rule names. Not a defect: a standing tax, paid at each call site, by whoever comes next.

The column is identified by its CONSTRAINT, never by its name

This is the part that decides whether the rule is worth having.

A rule that decided "this column carries a relation" by seeing _id at the end would find exactly the columns that already satisfy it, and would be blind to author, owner and parent — the ones it exists for. It would pass on every schema, including the ones it was written to fix.

So both halves ask the schema instead. The audit half reads the foreign keys the catalog reports; the lint half judges a statement only when the parse gives it a referenced target, which is what a REFERENCES clause produces and what nothing else does.

On PostgreSQL the canonicalization classifies ALTER TABLE … ADD CONSTRAINT … REFERENCES … as add_constraint rather than as a foreign-key kind of its own — MySQL classifies the same statement as add_foreign_key. The referenced target is what both engines agree on, which is why it and not the statement kind is what this rule reads.

A composite foreign key is not reported

A multi-column key references a compound key on the other side, and the parts of a compound key carry their own meaning: (country_code, area_code) is a reference whose columns are named after what they hold. _id is a convention about a surrogate key — one column standing in for a row — so demanding it of every member of a natural-key reference would report a false positive on exactly the schemas that modeled their keys most carefully.

Skipped on purpose, and said here so that a composite key going unreported is not read as an oversight.

Two cases that need no configuration

  • Self-referential. parent_id pointing at its own table ends in _id and passes on its own merits. There is nothing to special-case.
  • Polymorphic. commentable_id ends in _id and passes. Its partner commentable_type carries no foreign key at all — it cannot, since it names a class rather than a row — so it is invisible to a rule that reads constraints.

Configuring it

// config/sqlens.php
'audit' => [
'naming' => [
'foreign_key_suffix' => '_id', // the default
'exempt' => ['/^legacy_/'], // names this rule does not judge at all
],
],

An empty suffix is refused and the default is used instead. Every name ends with the empty string, so an empty setting would silence the rule completely — and a project would read "no findings" as "my keys are named the way I asked", when in fact it had asked for nothing.

Suppressing it

The ordinary routes apply: a baseline entry, an ignore rule in config/sqlens.php, or lowering the level below 8. A project that has chosen another convention should set the suffix rather than suppress the rule, so the rule keeps working for the columns that do not follow it either.

Sources

  • Eloquent relationships — the framework derives the foreign key from the relationship method name and requires it to be passed explicitly when the column does not match
  • PostgreSQL 18 — ALTER TABLE — what a FOREIGN KEY … REFERENCES clause declares, which is what identifies the column here