Complete CRUD API Resource Recipe
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);
}
}
storereturns 201, not 200 —res(201)->data(...)destroyreturns 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.
| Panel | Trait | Helper | Typical scope key |
|---|---|---|---|
Business | HasCurrentBranch | $this->getCurrentBranch() | branch_id |
Owner | HasCurrentOwner | $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
| Panel | Controller | Shape |
|---|---|---|
Business | FavoriteLocationController | Full CRUD |
Business | BranchCustomerController | Full CRUD, branch-scoped |
Business | BranchController | CRUD except(['destroy']) |
Owner | OwnerUserController | Full CRUD, owner-scoped; demonstrates $this->getCurrentOwner()->id |
Owner | OwnerCustomerController, OwnerDriverController | Full CRUD on owner-scoped child resources |
Owner | VoucherController | List + show with ->additional() totals |
Provider | BankAccountController | CRUD except(['update']) |
Provider | InvoiceController | Read-only (only(['index', 'show'])) |
What to read next
- RESTful API — the envelope every endpoint returns.
- Controllers — the thin-controller pattern.
- DTOs — typed payloads between request and Contract.
- Set up an Admin CRUD — the SSR counterpart of this recipe.
Admin CRUD
Step-by-step recipe for adding a new admin dashboard page — controller, route, views, permissions, translations, and sidebar menu — in the order to do them.
DDD Map
Every domain in the backend with a one-line purpose, legacy-code estimate, and DDD adherence score. Use this page to spot which domains are mid-refactor and which to use as templates.