Skip to content

Last updated:

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:

php
$table->id();
$table->foreignId('patient_visit_id')->constrained()->cascadeOnDelete();
// …exam-specific columns…
$table->timestamps();
$table->softDeletes();

1. Migration

bash
php artisan make:migration create_amsler_grids_table
php
return 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

php
// 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

bash
php artisan make:factory AmslerGridFactory

Always provide a default patient_visit_id so the factory can be called without args:

php
public function definition(): array
{
    return [
        'patient_visit_id' => PatientVisit::factory(),
        'eye' => 'ou',
        'notes' => fake()->sentence(),
    ];
}

4. Repository

php
// app/Repositories/AmslerGridRepository.php
class AmslerGridRepository extends AbstractFilterableRepository
{
    protected function model(): string
    {
        return AmslerGrid::class;
    }
}

Register in RepositoryServiceProvider:

php
$this->app->bind(AmslerGridRepositoryContract::class, AmslerGridRepository::class);

5. Service

php
// 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

bash
php artisan make:request AmslerGrid/StoreAmslerGridRequest
php artisan make:request AmslerGrid/UpdateAmslerGridRequest
php
public 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

php
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

php
// 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:

php
// 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

php
// 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 — string not decimal

CPR - Clinical Patient Records