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
php artisan make:model Reservation -mfsr
# -m migration, -f factory, -s seeder, -r controllerThen manually create:
app/Http/Requests/Reservation/StoreReservationRequest.phpapp/Http/Requests/Reservation/UpdateReservationRequest.phpapp/Http/Resources/ReservationResource.phpapp/Services/Reservation/ReservationService.phpapp/Repositories/ReservationRepository.php+ Contractroutes/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
// 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
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
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
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
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
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
// 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:
Route::middleware('permission:reservations.access')->group(function () {
Route::get('reservations', [ReservationController::class, 'index']);
});10. Test
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
