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 ​
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 ​
- Single transaction — both writes commit together or roll back together.
- Pure extraction —
extractPrpData()doesn't touch the DB; it just shapes the data. - Idempotent secondary write —
updateOrCreatekeyed 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:
// 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:
// 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 ​
// 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:
| Field | Indirect-Ophthalmoscopy endpoint | Direct PRP endpoint |
|---|---|---|
| Eye | prp_eye: od|os|ou (lowercase) | eye: OD|OS|OU (uppercase) |
| Shots | shots: [{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
updateOrCreatekeyed 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
