Skip to main content
Laravel, shipping fast.
// app/Http/Controllers/ConsumerController.php
public function destroy(Consumer $consumer): Response
{
    DB::transaction(function () use ($consumer) {
        $consumer->tokens()->delete();
        $consumer->forceFill(['settings' => null])->save();
        $consumer->delete();
    });

    return response()->noContent();
}

A 204 says “done, and there is nothing to send back.” Compare it with the 202 on DELETE /licenses/{license}: that one promised work for later. This one is finished when it answers, so it says so.

The order follows how dangerous each thing is to keep. The tokens go first, so the consumer can no longer authenticate. The settings go next, because they hold a partner’s provider credential, and there is no reason to keep a secret for an account that has left. forceFill() is needed because settings is not fillable.

Last, the row itself. Because the model uses SoftDeletes, delete() sets deleted_at and leaves the row. Every query from then on ignores it: it disappears from the list and route model binding answers 404.

Soft deletion is here for one reason. Removing a consumer by mistake takes down a partner’s integration, and with the row still there, an operator can restore() it under the same ID and name, then issue new tokens and enter the credentials again. It is not a retention policy. A soft-deleted row is still data you hold, and Chapter 17 removes it for good after a fixed period.

Testing Data You Own

With a database behind the endpoint, tests assert on two things: the response, and what is now true in the table.

// tests/Feature/ConsumerTest.php
it('lets an operator create a consumer', function () {
    Sanctum::actingAs(
        Consumer::factory()->create(),
        ['consumers:manage'],
    );

    $this->postJson('/api/consumers', ['name' => 'Partner A'])
        ->assertCreated()
        ->assertJsonPath('data.name', 'Partner A');

    $this->assertDatabaseHas('consumers', [
        'name' => 'Partner A',
    ]);
});

Add RefreshDatabase to the test case and every test starts from an empty schema. (Laravel documentation: Database Testing.)

Then the tests where the wrong caller asks. They are the ones nobody writes, because the feature works without them:

it('hides other consumers from a consumer', function () {
    $other = Consumer::factory()->create();

    Sanctum::actingAs(
        Consumer::factory()->create(),
        ['licenses:read'],
    );

    $this->getJson("/api/consumers/{$other->id}")
        ->assertNotFound();
});
it('refuses to issue tokens to a non-operator', function () {
    $other = Consumer::factory()->create();

    Sanctum::actingAs(
        Consumer::factory()->create(),
        ['licenses:write'],
    );

    $this->postJson("/api/consumers/{$other->id}/tokens", [
        'name' => 'stolen',
        'abilities' => ['consumers:manage'],
    ])->assertForbidden();

    expect($other->tokens()->count())->toBe(0);
});

The last line of the second test matters as much as the status. A 403 that still created the token would pass a test that only looked at the response. The status would be the same for a consumer that doesn’t exist, because the ability is checked before the record is loaded.

And one for the secret:

it('never exposes settings', function () {
    $consumer = Consumer::factory()
        ->forPartner('secret-token')
        ->create();

    Sanctum::actingAs($consumer, ['licenses:read']);

    $this->getJson("/api/consumers/{$consumer->id}")
        ->assertOk()
        ->assertJsonMissingPath('data.settings')
        ->assertDontSee('secret-token');
});

What Was Different

Set this chapter beside Chapters 2 and 3.

The parts that face the consumer are identical: a FormRequest at the boundary, a thin controller, a Resource that whitelists, data in the envelope, the same errors. A client can’t tell which of this API’s resources live in a database and which at a provider. That is the consistency the preface was about.

Everything behind the controller changed. Where licenses needed a contract, a driver, and a typed object, consumers needed a model. Route model binding replaced the lookup. Casts replaced the typed object. The paginator replaced nothing, because the provider never offered one.

Chapter 5 Summary

  • A table-backed resource needs a model, a FormRequest per action, a Resource, and a Policy for whatever depends on the record. Start with apiResource and name nothing by hand.
  • Scope nested resources, so a child in the URL must belong to its parent.
  • Let route model binding do the lookup and the 404.
  • Pass validated() to the model, never all(). Keep secrets out of Fillable, and mark them Hidden.
  • Back a unique rule with a unique index. Use sometimes and ignore() on updates.
  • In a Resource, use whenLoaded and whenCounted, so that it can never run a query.
  • Validate query parameters. Whitelist sort columns. Bound the page size on both sides.
  • Check abilities in middleware, before a record is loaded. Put per-record rules in a Policy, and answer 404 when the caller shouldn’t know the record exists.
  • Every new controller declares who may call it. An endpoint that issues credentials most of all.
  • A caller may grant only the abilities it holds.
  • Delete credentials and secrets outright. Soft-delete only what you may need to restore.
  • A 204 means done. A 202 means later. Say the one that is true.

The audio could not be loaded. Try again in a moment.