Skip to content

Last updated:

How-To: Couple a Secondary Resource to a Primary Endpoint ​

Some clinical workflows write to two different tables from a single API call — for example, the indirect ophthalmoscopy exam also persists PRP (panretinal photocoagulation) tracking data. Both belong to the same patient_visit_id, but they're conceptually distinct records.

Reference: app/Services/IndirectOphthalmoscopy/IndirectOphthalmoscopyService.php.

When to do this ​

  • The two resources share the same parent (here: patient_visit_id)
  • The frontend treats them as one form
  • Each row in the secondary table is at most one per parent (use updateOrCreate, keyed on the parent FK)

If the secondary resource is a true list (e.g., medications during a visit), make it its own endpoint instead.

The pattern ​

php
namespace App\Services\IndirectOphthalmoscopy;

use App\Models\IndirectOphthalmoscopy;
use App\Models\Prp;
use Illuminate\Support\Facades\DB;

class IndirectOphthalmoscopyService
{
    public function store(array $params): IndirectOphthalmoscopy
    {
        [$params, $prpData] = $this->extractPrpData($params);

        return DB::transaction(function () use ($params, $prpData) {
            $indirectOphthalmoscopy = IndirectOphthalmoscopy::create($params);

            if ($prpData !== null) {
                $this->syncPrp($indirectOphthalmoscopy->patient_visit_id, $prpData);
            }

            return $indirectOphthalmoscopy->load('prp');
        });
    }

    public function update(IndirectOphthalmoscopy $exam, array $data): IndirectOphthalmoscopy
    {
        [$data, $prpData] = $this->extractPrpData($data);

        return DB::transaction(function () use ($exam, $data, $prpData) {
            $exam->update($data);

            if ($prpData !== null) {
                $this->syncPrp($exam->patient_visit_id, $prpData);
            }

            return $exam->fresh('prp');
        });
    }

    private function extractPrpData(array $params): array
    {
        $prpData = null;
        if (array_key_exists('prp_eye', $params) || array_key_exists('shots', $params)) {
            $prpData = [
                'eye'   => $params['prp_eye'] ?? null,
                'shots' => $params['shots'] ?? [],
            ];
            unset($params['prp_eye'], $params['shots']);
        }

        return [$params, $prpData];
    }

    private function syncPrp(int $patientVisitId, array $prpData): void
    {
        Prp::updateOrCreate(
            ['patient_visit_id' => $patientVisitId],
            $prpData,
        );
    }
}

Three things this does right ​

  1. Single transaction — both writes commit together or roll back together.
  2. Pure extraction — extractPrpData() doesn't touch the DB; it just shapes the data.
  3. Idempotent secondary write — updateOrCreate keyed on the shared parent FK means re-submitting the form doesn't create duplicate PRP rows.

Model side ​

Add the relationship to the primary model so the eager-loaded resource can serialize it:

php
// app/Models/IndirectOphthalmoscopy.php
public function prp(): HasOne
{
    return $this->hasOne(Prp::class, 'patient_visit_id', 'patient_visit_id');
}

Note: the relationship is on patient_visit_id, not on the primary key. PRP has no indirect_ophthalmoscopy_id column — both tables just share the visit.

Validation ​

Both store and update form requests must include the secondary fields:

php
// app/Http/Requests/IndirectOphthalmoscopy/StoreIndirectOphthalmoscopyRequest.php
public function rules(): array
{
    return [
        'patient_visit_id' => ['required', 'exists:patient_visits,id'],
        // …exam fields…

        // Coupled PRP fields
        'prp_eye'           => ['nullable', 'in:od,os,ou'],
        'shots'             => ['nullable', 'array'],
        'shots.*.date'      => ['required_with:shots', 'date'],
        'shots.*.shot'      => ['required_with:shots', 'integer', 'min:0'],
    ];
}

Resource ​

php
// app/Http/Resources/IndirectOphthalmoscopyResource.php
public function toArray($request): array
{
    return [
        'id'               => $this->id,
        'patient_visit_id' => $this->patient_visit_id,
        // …exam fields…

        // Surfaced from the PRP relationship
        'prp_eye' => $this->prp?->eye,
        'shots'   => $this->prp?->shots ?? [],
    ];
}

Eager-load prp from the controller's index() to avoid N+1.

⚠️ Beware multiple write paths to the same table ​

prps is also written by /api/v1/prps directly. The field shapes differ between the two endpoints:

FieldIndirect-Ophthalmoscopy endpointDirect PRP endpoint
Eyeprp_eye: od|os|ou (lowercase)eye: OD|OS|OU (uppercase)
Shotsshots: [{date, shot}]shots: [{date, eye, shots}]

If you change the schema of one path, change the other and document the difference. See memory: prp-two-write-paths.

Checklist ​

  • [ ] Both writes wrapped in DB::transaction
  • [ ] Secondary write uses updateOrCreate keyed on the shared FK
  • [ ] Extraction step is pure (no DB) for testability
  • [ ] Form request validates both primary and secondary fields
  • [ ] Resource surfaces secondary fields via the relationship
  • [ ] Controller index() eager-loads the secondary relationship
  • [ ] If a separate endpoint also writes to the secondary table, field shapes are documented

CPR - Clinical Patient Records