Going Further

Complete CRUD API Resource Recipe

Step-by-step recipe for a full CRUD JSON endpoint on any API panel — Business, Customer, Owner, Provider — backed by an existing domain Contract.

Follow this recipe when adding a full CRUD JSON endpoint to any of the API panels — Business, Customer, Owner, Provider — backed by an existing domain Contract. The shape is panel-agnostic; substitute {Panel} for whichever you're working in.

For SSR admin pages (Blade + redirect), use Set up an Admin CRUD instead.

Six pieces, in order: FormRequests → Controller → Routes → API Resource → Contract methods → Envelope.

1. FormRequests

Create typed FormRequests in src/Presentation/{Panel}/Requests/{Feature}/:

  • Index{Entity}Request.php — validates query-string filters, sort, pagination. API index endpoints validate filters too — never skip this because it's a GET.
  • Store{Entity}Request.php — validates the create payload.
  • Update{Entity}Request.php — validates the update payload (nullable rules for partial updates).
namespace Presentation\{Panel}\Requests\{Feature};

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class Store{Entity}Request extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'      => ['required', 'string', 'max:255'],
            'email'     => ['required', 'email', Rule::unique('{entities}', 'email')],
            'is_active' => ['required', 'boolean'],
        ];
    }
}

A 422 failure is wrapped in the project envelope automatically — never hand-roll response()->json([...], 422). See RESTful API → Validation errors.

2. Controller

Create in src/Presentation/{Panel}/Controllers/{Entity}Controller.php. Every method declares : ResponseBuilder — this is the project's API contract.

namespace Presentation\{Panel}\Controllers;

use Domain\Core\Helpers\ResponseBuilder;
use Domain\{Domain}\Contracts\{Entity}Contract;
use Domain\{Domain}\DataTransferObjects\{Entity}\Create{Entity}Data;
use Domain\{Domain}\DataTransferObjects\{Entity}\Filter{Entity}Data;
use Domain\{Domain}\DataTransferObjects\{Entity}\Update{Entity}Data;
use Presentation\{Panel}\Requests\{Feature}\Index{Entity}Request;
use Presentation\{Panel}\Requests\{Feature}\Store{Entity}Request;
use Presentation\{Panel}\Requests\{Feature}\Update{Entity}Request;
use Presentation\{Panel}\Resources\{Feature}\{Entity}Resource;

class {Entity}Controller extends Controller
{
    public function __construct(
        private readonly {Entity}Contract ${entity}Contract,
    ) {}

    public function index(Index{Entity}Request $request): ResponseBuilder
    {
        return res()->data(
            {Entity}Resource::collection(
                $this->{entity}Contract->get{Entities}(
                    Filter{Entity}Data::fromArray([
                        ...$request->validated(),
                        '{scope}_id' => $this->getCurrent{Scope}()->id,
                    ]),
                )->pagination(),
            ),
        );
    }

    public function show(string $id): ResponseBuilder
    {
        return res()->data(
            {Entity}Resource::make(
                $this->{entity}Contract->get{Entity}(
                    id: $id,
                    data: Filter{Entity}Data::fromArray([
                        '{scope}_id' => $this->getCurrent{Scope}()->id,
                    ]),
                ),
            ),
        );
    }

    public function store(Store{Entity}Request $request): ResponseBuilder
    {
        return res(201)->data(
            {Entity}Resource::make(
                $this->{entity}Contract->create(
                    Create{Entity}Data::fromArray([
                        ...$request->validated(),
                        '{scope}_id' => $this->getCurrent{Scope}()->id,
                    ]),
                ),
            ),
        );
    }

    public function update(string $id, Update{Entity}Request $request): ResponseBuilder
    {
        ${entity} = $this->{entity}Contract->get{Entity}(
            id: $id,
            data: Filter{Entity}Data::fromArray([
                '{scope}_id' => $this->getCurrent{Scope}()->id,
            ]),
        );

        return res()->data(
            {Entity}Resource::make(
                $this->{entity}Contract->update(
                    {entity}: ${entity},
                    data: Update{Entity}Data::fromArray($request->validated()),
                ),
            ),
        );
    }

    public function destroy(string $id): ResponseBuilder
    {
        ${entity} = $this->{entity}Contract->get{Entity}(
            id: $id,
            data: Filter{Entity}Data::fromArray([
                '{scope}_id' => $this->getCurrent{Scope}()->id,
            ]),
        );

        $this->{entity}Contract->delete(${entity});

        return res(204);
    }
}
Status codes most often forgotten — get these two right:
  • store returns 201, not 200 — res(201)->data(...)
  • destroy returns 204 with no body — res(204)

Server-enforced scope

Every panel has a trait that exposes the authenticated subject. The scope key goes after ...$request->validated() in the DTO array so it overrides any matching key the client tries to inject — a user must never be able to manage another tenant's records by passing a different id in the body.

PanelTraitHelperTypical scope key
BusinessHasCurrentBranch$this->getCurrentBranch()branch_id
OwnerHasCurrentOwner$this->getCurrentOwner()owner_id
Customer(auth user)$request->user()customer_id
Provider(auth user)$request->user()provider_id

3. Routes

Add a use import and a Route::apiResource(...) line in src/Presentation/{Panel}/Routes/api.php, inside the authenticated group for the matching version prefix.

use Presentation\{Panel}\Controllers\{Entity}Controller;

Route::prefix('api/v2/{panel}')->name('{panel}.')->middleware(['api', 'apiProcessing'])->group(function () {
    Route::middleware('auth:{guard}')->group(function () {
        Route::apiResource('{entities}', {Entity}Controller::class);
    });
});

Route::apiResource registers index / show / store / update / destroy (no create / edit — those are SSR-only). The URI follows /api/v2/{panel}/{entities}; singular nouns or verb-in-URL forms (get{Entities}) are wrong.

4. API Resource

Create src/Presentation/{Panel}/Resources/{Feature}/{Entity}Resource.php. The Resource only shapes the payload — it never queries.

namespace Presentation\{Panel}\Resources\{Feature};

use Illuminate\Http\Resources\Json\JsonResource;

class {Entity}Resource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'        => $this->id,
            'name'      => $this->name,
            'is_active' => $this->is_active,
            'related'   => [
                'id'   => $this->related?->id,
                'name' => $this->related?->display_name,
            ],
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

Guard related fields with whenLoaded() to avoid N+1 inside a Resource — see Queries for the eager-loading rule.

5. Contract methods

The Controller calls the domain {Entity}Contract. If the methods aren't there yet, add them on the Contract + Service + Action triplet. Reach-around (Model::find(...) directly in the controller) is a Critical anti-pattern — see Anti-Patterns.

Typical method set for a CRUD resource:

interface {Entity}Contract
{
    public function get{Entities}(Filter{Entity}Data $data): PaginatedActionResult;
    public function get{Entity}(string $id, Filter{Entity}Data $data): {Entity};
    public function create(Create{Entity}Data $data): {Entity};
    public function update({Entity} ${entity}, Update{Entity}Data $data): {Entity};
    public function delete({Entity} ${entity}): void;
}

See Services, Contracts, and Actions for the wiring.

6. Response envelope (automatic)

Every API response wraps in the project envelope:

{
  "status": { "code": 200, "success": true, "error_key": "ok", "message": "ok" },
  "data": { /* {Entity}Resource shape */ }
}

ResponseBuilder (returned from res()) handles this — Laravel converts it to JSON automatically via Responsable. Never return a raw JsonResponse, an array, a Resource directly, or response()->json(...) from an API controller — that bypasses the envelope. See RESTful API for the full envelope schema.

Going further

Subset of actions

For a read-only resource:

Route::apiResource('{entities}', {Entity}Controller::class)->only(['index', 'show']);

For CRUD without delete:

Route::apiResource('{entities}', {Entity}Controller::class)->except(['destroy']);

Drop the matching controller methods, FormRequests, and Contract methods when an action is excluded.

Nested resources — only one level deep

URL nesting is capped at one level. Past that, flatten and filter via query string:

✅ GET /api/v2/{panel}/quotations?order_id=42
❌ GET /api/v2/{panel}/orders/42/quotations

Per-record ability check

Beyond panel-wide auth middleware, gate individual records before mutating:

public function destroy(string $id): ResponseBuilder
{
    ${entity} = $this->{entity}Contract->get{Entity}($id, /* … */);
    ${entity}->hasAbility('delete');
    $this->{entity}Contract->delete(${entity});

    return res(204);
}

See Security for the Model abilities pattern.

Index extras — totals and sidecar data

API Resource collections accept ->additional([...]) to inject totals or aggregated metadata into the paginated response without polluting the per-item shape:

return res()->data(
    Index{Entity}Resource::collection(${entities}->pagination())
        ->additional([
            'totals' => [
                'remaining' => ${entities}->builder()->sum('remaining_total'),
                'total'     => ${entities}->builder()->sum('total'),
            ],
        ]),
);

Localised messages

res()->message('domain.key')->data(...) puts a localised string into status.message. Don't hardcode English.

Live examples to crib from

PanelControllerShape
BusinessFavoriteLocationControllerFull CRUD
BusinessBranchCustomerControllerFull CRUD, branch-scoped
BusinessBranchControllerCRUD except(['destroy'])
OwnerOwnerUserControllerFull CRUD, owner-scoped; demonstrates $this->getCurrentOwner()->id
OwnerOwnerCustomerController, OwnerDriverControllerFull CRUD on owner-scoped child resources
OwnerVoucherControllerList + show with ->additional() totals
ProviderBankAccountControllerCRUD except(['update'])
ProviderInvoiceControllerRead-only (only(['index', 'show']))