How-To: Add a Clinical Exam Module
The CPR backend hosts ~14 ophthalmic exam types (tonometry, refraction, slit lamp, biometry, indirect ophthalmoscopy, etc.) and they all follow the same pattern. This guide walks the full new-exam recipe so you don't have to reverse-engineer it from an existing module.
Reference modules:
The pattern
PatientVisit (parent)
└── Exam (one record per visit, sometimes per eye)All exam tables share these columns:
$table->id();
$table->foreignId('patient_visit_id')->constrained()->cascadeOnDelete();
// …exam-specific columns…
$table->timestamps();
$table->softDeletes();1. Migration
php artisan make:migration create_amsler_grids_tablereturn new class extends Migration {
public function up(): void {
Schema::create('amsler_grids', function (Blueprint $table) {
$table->id();
$table->foreignId('patient_visit_id')->constrained()->cascadeOnDelete();
$table->string('eye', 8)->nullable(); // od|os|ou
$table->json('findings_od')->nullable();
$table->json('findings_os')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
};Use strings for legacy-imported measurements
Some exam columns that look numeric (biometry AK, axial length, IOL power) are stored as string, not decimal, because the legacy data carries alphanumeric suffixes ("23.5L", "+20.0X"). See commit 5a2a5a9 feat(biometry): store all measurement columns as alphanumeric strings. If you're importing legacy data, default new measurement columns to nullable string.
2. Model
// app/Models/AmslerGrid.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
class AmslerGrid extends Model
{
use HasFactory, LogsActivity, SoftDeletes;
protected $fillable = ['patient_visit_id', 'eye', 'findings_od', 'findings_os', 'notes'];
protected $casts = [
'findings_od' => 'array',
'findings_os' => 'array',
];
public function patientVisit(): BelongsTo
{
return $this->belongsTo(PatientVisit::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty();
}
}3. Factory & Seeder
php artisan make:factory AmslerGridFactoryAlways provide a default patient_visit_id so the factory can be called without args:
public function definition(): array
{
return [
'patient_visit_id' => PatientVisit::factory(),
'eye' => 'ou',
'notes' => fake()->sentence(),
];
}4. Repository
// app/Repositories/AmslerGridRepository.php
class AmslerGridRepository extends AbstractFilterableRepository
{
protected function model(): string
{
return AmslerGrid::class;
}
}Register in RepositoryServiceProvider:
$this->app->bind(AmslerGridRepositoryContract::class, AmslerGridRepository::class);5. Service
// app/Services/AmslerGrid/AmslerGridService.php
class AmslerGridService
{
public function __construct(protected AmslerGridRepositoryContract $repo) {}
public function listForVisit(int $visitId): Collection
{
return $this->repo->whereVisit($visitId)->get();
}
public function store(array $data): AmslerGrid {
return AmslerGrid::create($data);
}
public function update(AmslerGrid $grid, array $data): AmslerGrid {
$grid->update($data);
return $grid->fresh();
}
public function destroy(AmslerGrid $grid): void {
$grid->delete();
}
}If the exam writes to two tables (one parent + one secondary like PRP), use Couple a Secondary Resource.
6. Form Requests
php artisan make:request AmslerGrid/StoreAmslerGridRequest
php artisan make:request AmslerGrid/UpdateAmslerGridRequestpublic function rules(): array
{
return [
'patient_visit_id' => ['required', 'exists:patient_visits,id'],
'eye' => ['nullable', 'in:od,os,ou'],
'findings_od' => ['nullable', 'array'],
'findings_os' => ['nullable', 'array'],
'notes' => ['nullable', 'string'],
];
}For update requests, swap required for sometimes.
7. API Resource
class AmslerGridResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'patient_visit_id' => $this->patient_visit_id,
'eye' => $this->eye,
'findings_od' => $this->findings_od,
'findings_os' => $this->findings_os,
'notes' => $this->notes,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}8. Controller
// app/Http/Controllers/Api/V1/AmslerGridController.php
class AmslerGridController extends Controller
{
public function __construct(protected AmslerGridService $service) {}
public function index(int $visitId): AnonymousResourceCollection
{
return AmslerGridResource::collection($this->service->listForVisit($visitId));
}
public function store(StoreAmslerGridRequest $request): AmslerGridResource
{
return new AmslerGridResource($this->service->store($request->validated()));
}
public function update(UpdateAmslerGridRequest $request, AmslerGrid $amslerGrid): AmslerGridResource
{
return new AmslerGridResource($this->service->update($amslerGrid, $request->validated()));
}
public function destroy(AmslerGrid $amslerGrid): JsonResponse
{
$this->service->destroy($amslerGrid);
return response()->json(null, 204);
}
}9. Routes
Create a new file under routes/api/v1/ — one file per exam keeps the manifest tidy:
// routes/api/v1/amsler_grids.php
use App\Http\Controllers\Api\V1\AmslerGridController;
use Illuminate\Support\Facades\Route;
Route::get('patient-visits/{visit}/amsler-grids', [AmslerGridController::class, 'index']);
Route::post('amsler-grids', [AmslerGridController::class, 'store']);
Route::put('amsler-grids/{amslerGrid}', [AmslerGridController::class, 'update']);
Route::delete('amsler-grids/{amslerGrid}', [AmslerGridController::class, 'destroy']);Then require it from routes/api.php inside the branch-scoped group (other exam files are already wired the same way).
10. Update endpoints take POST, not PUT
POST + spoofMethod:false from the frontend
The patient and corneal-topography update endpoints accept file uploads, so they are direct POST instead of spoofed PUT. If your exam supports file uploads, do the same — and remember the frontend must pass spoofMethod: false to Inertia, or Laravel will spoof PUT and return 405.
See memory: update-route-post-vs-spoofed-put.
11. Test
// tests/Feature/Api/V1/AmslerGrid/AmslerGridStoreTest.php
use App\Models\PatientVisit;
it('stores an amsler grid for a visit', function () {
$visit = PatientVisit::factory()->create();
Sanctum::actingAs($user = User::factory()->create());
$user->givePermissionTo('clinical.access');
$response = $this->postJson('/api/v1/amsler-grids', [
'patient_visit_id' => $visit->id,
'eye' => 'ou',
'notes' => 'No metamorphopsia.',
]);
$response->assertCreated();
expect(AmslerGrid::count())->toBe(1);
});Checklist
- [ ] Migration includes
patient_visit_id,softDeletes,timestamps - [ ] Model uses
LogsActivity+SoftDeletes - [ ] Factory has a default
patient_visit_id - [ ] Repository extends
AbstractFilterableRepository - [ ] Service is the only thing the controller calls
- [ ] Routes in their own file under
routes/api/v1/, wired inside the branch-scoped group - [ ] Tests cover store, update, destroy, validation failures
- [ ] If file uploads — POST instead of PUT + frontend
spoofMethod: false - [ ] If legacy-imported measurement columns —
stringnotdecimal
