Skip to content

Last updated:

How-To: Add an API CRUD Resource

End-to-end recipe for a new REST resource on /api/v1/*. Use this for any non-exam resource (configuration, lookups, queue actions, etc.). For per-visit exams, use Add a Clinical Exam Module — same skeleton with more conventions.

1. Scaffold

bash
php artisan make:model Reservation -mfsr
# -m migration, -f factory, -s seeder, -r controller

Then manually create:

  • app/Http/Requests/Reservation/StoreReservationRequest.php
  • app/Http/Requests/Reservation/UpdateReservationRequest.php
  • app/Http/Resources/ReservationResource.php
  • app/Services/Reservation/ReservationService.php
  • app/Repositories/ReservationRepository.php + Contract
  • routes/api/v1/reservations.php

2. The layer rules

Controller → Service → Repository → Model → DB
  • Controller only orchestrates. It calls one service method per action and returns a Resource.
  • Service holds business logic and transactions.
  • Repository owns Eloquent queries — controllers never touch Reservation::query() directly.
  • DTOs (app/Dtos/) are only for pipelines (auth, patient creation). Skip DTOs for plain CRUD.

3. Route file

php
// routes/api/v1/reservations.php
use App\Http\Controllers\Api\V1\Queue\ReservationController;
use Illuminate\Support\Facades\Route;

Route::get('reservations', [ReservationController::class, 'index']);
Route::post('reservations', [ReservationController::class, 'store']);
Route::get('reservations/{reservation}', [ReservationController::class, 'show']);
Route::put('reservations/{reservation}', [ReservationController::class, 'update']);
Route::put('reservations/{reservation}/cancel', [ReservationController::class, 'cancel']);
Route::post('reservations/{reservation}/check-in', [ReservationController::class, 'checkIn']);

Then require it from routes/api.php inside the branch-scoped group. Branch isolation only applies to routes inside that group; anything outside leaks across branches.

Don't name a method options

See Avoid Wayfinder Collisions — methods named options, delete, import, export, or default break the Wayfinder TypeScript generator.

4. Controller

php
namespace App\Http\Controllers\Api\V1\Queue;

class ReservationController extends Controller
{
    public function __construct(protected ReservationService $service) {}

    public function index(Request $request): AnonymousResourceCollection
    {
        return QueueReservationResource::collection(
            $this->service->paginate($request->validated())
        );
    }

    public function store(StoreReservationRequest $request): QueueReservationResource
    {
        return new QueueReservationResource(
            $this->service->book($request->validated())
        );
    }

    public function cancel(QueueReservation $reservation): JsonResponse
    {
        $this->service->cancel($reservation);

        return response()->json(['message' => 'Reservation cancelled.']);
    }
}

Use route model binding (QueueReservation $reservation) so 404s are automatic.

5. Service

php
class ReservationService
{
    public function __construct(
        protected QueueReservationRepository $repo,
    ) {}

    public function book(array $data): QueueReservation
    {
        $this->assertCapacity($data['branch_id'], $data['reserved_for']);

        if (Carbon::parse($data['reserved_for'])->isPast()) {
            throw new ReservationException('Cannot reserve a past date.');
        }

        return DB::transaction(fn () =>
            $this->repo->create($data)
        );
    }

    private function assertCapacity(int $branchId, string $date): void
    {
        $limit = config('reservations.daily_limit', 50);
        $count = $this->repo->countForDate($branchId, $date);

        if ($count >= $limit) {
            throw new ReservationException(
                "Daily reservation limit ({$limit}) reached for {$date}."
            );
        }
    }
}

Throw App\Exceptions\* for business rule violations. The exception handler maps these to clean JSON 422/409 responses — controllers don't need try/catch.

6. FormRequest

php
class StoreReservationRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'branch_id'     => ['required', 'exists:branches,id'],
            'patient_id'    => ['required', 'exists:patients,id'],
            'service_id'    => ['required', 'exists:branch_services,id'],
            'reserved_for'  => ['required', 'date', 'after_or_equal:today'],
            'channel'       => ['required', new Enum(QueueReservationChannel::class)],
            'notes'         => ['nullable', 'string', 'max:500'],
        ];
    }
}

For Update requests, swap required for sometimes.

7. Resource

php
class QueueReservationResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'           => $this->id,
            'patient'      => new PatientResource($this->whenLoaded('patient')),
            'reserved_for' => $this->reserved_for->toDateString(),
            'status'       => $this->status->value,
            'channel'      => $this->channel->value,
            'notes'        => $this->notes,
            'created_at'   => $this->created_at->toIso8601String(),
        ];
    }
}

Use whenLoaded() for relationships so the API doesn't trigger N+1 if the controller forgot to eager-load.

8. Repository

php
class QueueReservationRepository
{
    public function paginate(array $filters): LengthAwarePaginator
    {
        return QueueReservation::query()
            ->forBranch($filters['branch_id'])
            ->when(isset($filters['date']), fn ($q) => $q->whereDate('reserved_for', $filters['date']))
            ->when(isset($filters['status']), fn ($q) => $q->where('status', $filters['status']))
            ->with(['patient', 'service'])
            ->orderBy('reserved_for')
            ->paginate(20);
    }

    public function countForDate(int $branchId, string $date): int
    {
        return QueueReservation::query()
            ->forBranch($branchId)
            ->whereDate('reserved_for', $date)
            ->whereNotIn('status', [QueueReservationStatus::CANCELLED, QueueReservationStatus::NO_SHOW])
            ->count();
    }
}

9. Permissions

php
// config/permissions.php
'reservations.access',
'reservations.create',
'reservations.update',
'reservations.cancel',

Then run php artisan cpr:permissions:sync and grant to roles in RoleSeeder. See Add a Permission.

Wire to the route group:

php
Route::middleware('permission:reservations.access')->group(function () {
    Route::get('reservations', [ReservationController::class, 'index']);
});

10. Test

php
beforeEach(function () {
    $this->user = User::factory()->create();
    $this->user->givePermissionTo(['reservations.access', 'reservations.create']);
    Sanctum::actingAs($this->user);
});

it('books a reservation', function () {
    $patient = Patient::factory()->create();
    $service = BranchService::factory()->create();

    $response = $this->postJson('/api/v1/reservations', [
        'branch_id'    => $service->branch_id,
        'patient_id'   => $patient->id,
        'service_id'   => $service->id,
        'reserved_for' => now()->addDays(2)->toDateString(),
        'channel'      => 'phone',
    ], ['X-Branch-Id' => $service->branch_id]);

    $response->assertCreated();
});

it('rejects past dates', function () {
    // …422 with a clean error message
});

it('rejects when daily limit is reached', function () {
    // …422 from ReservationException
});

it('requires reservations.create permission', function () {
    $this->user->revokePermissionTo('reservations.create');
    $response = $this->postJson('/api/v1/reservations', $payload);
    $response->assertForbidden();
});

The standard four assertions for any CRUD: happy path, validation failure, missing branch context (400), and permission denied (403).

Checklist

  • [ ] Controller calls one service method per action
  • [ ] Service throws App\Exceptions\* for business rules
  • [ ] Repository owns all queries
  • [ ] Route file in routes/api/v1/, required inside the branch-scoped group
  • [ ] No controller method named options, delete, import, export, default
  • [ ] Resource uses whenLoaded() for relationships
  • [ ] Permission declared + granted + enforced via permission: middleware
  • [ ] Tests for happy path, validation, branch context, permission

CPR - Clinical Patient Records