0% прочитано

Database Constraints as the Last Line of Defense

A PHP validation check can be correct and still lose to a concurrent request. I show why important invariants also need unique indexes, foreign keys, and other database constraints.

3 жовтня 2026 р. 7 хв читанняLaravel

Checking a rule in application code may appear sufficient.

For example: look for an active record, confirm that none exists, and then create one.

That works during sequential execution.

But two concurrent requests may pass the same check at the same time and both observe that no record exists yet.

For important rules, I therefore separate two responsibilities: application code explains the rule and controls behavior, while the database guarantees that an invalid state cannot actually be persisted.

Application validation answers "may I continue now?". A database constraint answers the stronger question: "can an invalid state be persisted at all?".

Application Validation Still Matters

PHP - Normal check before creation
$existing = $repository->findActiveByOwner(    $ownerId,); if ($existing !== null) {    throw ActiveRecordAlreadyExists::forOwner(        $ownerId,    );} $repository->add(    Record::create($ownerId),);

I do not remove this check simply because the same invariant is also protected by the database.

It remains useful because it:

  • fails invalid operations early;
  • produces a meaningful application error;
  • does not force database exceptions to become normal control flow;
  • keeps the rule visible in the code.

The problem is not the validation itself. The problem is assuming that concurrency is impossible after that check.

Two Requests Can Pass the Same Check

Validation does not make the operation atomic
Request A                Request B find existing            find existing    |                        |nothing found            nothing found    |                        |validation OK            validation OK    |                        |INSERT                   INSERT

Both requests behave correctly here.

Each performs the check honestly and receives the correct result at read time.

But there is a time gap between SELECT and INSERT.

If the rule exists only in PHP, both rows may be created.

A rule such as "only one active record may exist for this key" therefore needs a guarantee closer to where the state is actually persisted.

UNIQUE Makes the Invalid State Impossible to Persist

PHP - Database-level uniqueness
Schema::create('example_records', function (Blueprint $table): void {    $table->uuid('id')->primary();     $table->uuid('owner_id');    $table->string('environment');     $table->unique([        'owner_id',        'environment',    ]);});

With a unique constraint, the situation changes.

Two requests may still pass the early application check at the same time.

But both conflicting rows can no longer be persisted successfully.

One write succeeds. The other reaches a uniqueness conflict.

Concurrency still exists. What changes is more important: the invalid final state becomes impossible to persist.

I do not use UNIQUE to prevent concurrent requests. I use it to prevent concurrency from breaking the invariant.

I Translate the Database Failure into a Meaningful Conflict

PHP - Translate persistence failure into an application conflict
try {    ExampleRecord::query()->create([        'owner_id' => $record->ownerId(),        'environment' => $record->environment(),    ]);} catch (QueryException $exception) {    if (! $this->isUniqueViolation($exception)) {        throw $exception;    }     throw WriteConflict::detected(        previous: $exception,    );}

I do not want a raw SQL exception to accidentally reach the controller as public error text.

The persistence layer understands that a specific constraint violation represents a write conflict and translates that technical failure into a meaningful error for the higher layer.

Unknown database failures are not hidden. If the exception is not the expected constraint violation, the original failure continues upward.

This Does Not Move Business Logic into the Database

There is an important boundary here.

The fact that the database protects an invariant does not mean the database should own the business meaning of that invariant.

Application or domain code may still know that only one particular state is allowed and return a meaningful failure before persistence.

The unique constraint adds a technical guarantee that concurrency or another write path cannot violate the rule.

UNIQUE Is Only One Kind of Database Guarantee

The same principle applies to more than unique indexes.

The database can protect several kinds of integrity:

  • UNIQUE prevents duplicate values that must remain unique;
  • FOREIGN KEY prevents references to missing parent rows;
  • NOT NULL prevents required values from disappearing;
  • CHECK, where appropriate, restricts allowed values;
  • composite indexes can protect uniqueness across several columns.

I use these constraints where the invariant can be reliably expressed at the data level.

A Foreign Key Protects Integrity Beyond Laravel Code

PHP - The relationship is protected by the database
$table->foreignUuid('parent_id')    ->constrained('parents')    ->restrictOnDelete();

There is another reason to use database constraints: not every future write is guaranteed to pass through the same PHP method.

Another use case, import, console command, background process, or future code path may appear later.

If the relationship is truly required, a foreign key protects it regardless of which code path executed the INSERT or DELETE.

Constraints Do Not Replace Transactions or Locking

UNIQUE is not a universal solution for all concurrency problems.

If an operation changes several related rows or depends on authoritative current state, a transaction, conditional UPDATE, or lockForUpdate() may still be required.

A database constraint solves a different problem: it protects a specific invariant regardless of which concurrent path produced the invalid write attempt.

The Dangerous Assumption: "We Already Checked It in Code"

For a critical invariant, "we already check this before save()" is not enough for me.

I also ask:

  • can two requests pass that check at the same time;
  • are there other write paths;
  • will the database remain valid after a partial failure;
  • can the invariant be represented directly by the schema.

If the database can enforce the rule unambiguously, I usually do not leave that guarantee only in PHP.

I Use Several Protection Layers for Different Responsibilities

Each layer solves a different problem
Application code    |    +--> explicit rule    +--> early validation    +--> meaningful failure Transaction / locking    |    +--> protect critical change    +--> authoritative state Database constraint    |    +--> final integrity guarantee

These layers are not duplicates of one another.

Application validation provides understandable behavior.

A transaction or lock protects an operation when it must coordinate with concurrent state changes.

A schema constraint guarantees that the database cannot accept a state that violates a fundamental data invariant.

Which Rules I Try to Protect at the Database Level

I pay particular attention to database constraints when an invariant sounds like:

  • "this value must be unique";
  • "this combination of fields may exist only once";
  • "a child row cannot exist without its parent";
  • "this value is required";
  • "the value must belong to a strictly limited set";
  • "two concurrent requests must not be able to create two equally valid records".

Not every business rule can or should be moved into SQL. But I try not to leave fundamental data-integrity rules dependent only on application code behaving perfectly.

Protect the Rule at the Last Boundary Where It Can Be Violated

For me, database constraints are not a replacement for good application code.

Good code still explains the rule, validates it early, and produces meaningful failures.

The database provides the final guarantee: an invalid state cannot become persistent merely because two requests arrived at the same time or a new write path appeared later.

If data integrity depends on an invariant the database can enforce directly, I do not rely only on every future line of PHP code executing in exactly the right order.

Application code helps avoid an invalid write. A database constraint guarantees that the invalid write cannot become part of the data.