Three features, one Blade bug, and a backfill command
The Starting Point
I had a Laravel 12 modular app — a platform for managing customer onboarding workflows, service requests, and data imports. The session started with three separate requests that all touched the same area of the codebase:
- Open up the data import feature — it was locked to admin users, but anyone with the right permission should be able to use it.
- Show rejection details on onboarding cards — when an external system rejects a request, the card should show why, what type it was, and link to the original message.
- Auto-update workflow tasks from inbound messages — a specific response message type was being matched to records but never actually progressing the workflow forward.
Plus a quick fix: a page was showing generated placeholder IDs instead of "N/A" when data was missing.
What Actually Happened
Feature 1: Permission change (the one that bit me)
This seemed like the simplest change. The import card and delete buttons on a data listing page were gated by @if(Auth::user()->is_admin). I needed to swap those to use the app's custom permission system instead.
The app uses Blade::if('canPermission', ...) to register a custom Blade directive. So I changed the checks from @if(Auth::user()->is_admin) to @canPermission('module_name') and the closing tags to @endCanPermission.
I also had to add new routes — the import POST and delete routes only existed under the admin middleware group. I added duplicates under the permission-based group so the form actions would actually work.
Everything looked clean. I moved on to the next feature.
Then I came back to test it and got this:
ParseError - Internal Server Error
syntax error, unexpected token "endforeach", expecting "elseif" or "else" or "endif"
Line 450 of the view. Blade was confused about the directive nesting.
The bug? A single capital letter. Laravel's Blade::if() registers directives where the closing tag uses a lowercase prefix: @endcanPermission, not @endCanPermission. That capital C after end made Blade treat it as an unknown directive, which threw off the entire directive stack. The @endforeach further down the file then had no matching @foreach as far as the compiler was concerned.
Two characters. Broke the whole page.
The fix was a one-line replace_all:
// Wrong
@endCanPermission
// Right
@endcanPermission
Feature 2: Rejection details on onboarding cards
The onboarding page shows a table of cases, each with a visual progress timeline. When an external system rejects a request, the timeline dot turns red — but that was all the user got. No reason code, no link to the source message, no context. Just a red dot.
I needed to:
-
Query the data — rejected records are linked to cases via a foreign key, and each points to the raw inbound message. The rejection reason lives in the parsed XML payload of that message.
-
Classify the rejection — the platform tracks multiple request types per record. A rejection targets a specific one. I match the rejected message's identifier against the stored identifiers to determine which type was affected.
-
Show it in the view — I added a red info box above the timeline when a rejection exists, showing the reason code, the affected type, and a link to view the full message. I also changed a date column header to "Transaction Date" so it shows the rejection date when relevant.
The controller needed a new query to eager-load rejected records with their messages, grouped by case ID. Straightforward whereIn + groupBy pattern.
Feature 3: Auto-updating workflow tasks from responses
This was the most interesting one. The platform has an export/import flow:
- A record is created locally and exported (e.g. a data submission to an external system)
- The external system processes it and sends back a response message
- The response gets imported and matched to the original record
Step 3 was already working — the response message was matched to the outbound record and its status updated to accepted/rejected. But the onboarding workflow task was never touched. The task would sit at "pending" forever even though the system knew the response had come in.
The fix had two paths:
Path A (via existing records): When a response message matches an outbound record, look up the linked case and update the workflow task.
Path B (no matching outbound record): Sometimes submissions happen outside the platform. A response message comes in but there's no outbound record to match. In this case, I match by identifier — find a record with that ID prefix, look up its linked onboarding case, and update the task directly.
Both paths mark the task as completed (accepted) or rejected (with error code), then call the status sync service to advance the overall workflow.
I also added a response info box on the onboarding card — green for accepted, red for rejected — following the same pattern as the rejection box from Feature 2.
The backfill commands
All three features had the same problem: existing data. Cases that were already rejected wouldn't show details because the link between records was never set. Workflow tasks that should have been completed were still showing as pending.
I wrote two artisan commands:
-
Backfill rejection links — finds rejected outbound records with no linked message, matches them to existing response messages by identifier, and sets the link.
-
Backfill submission responses — two steps: first handles records that have a response but the task wasn't updated, then handles cases with no outbound record at all (matches by identifier to existing messages).
Both commands are registered in the admin system settings page as utility commands with a "Run Now" button — no SSH access needed on production.
What Worked
The final commit touched 11 files and added 370 lines. The three features, the N/A fix, and the Blade bug fix all shipped in a single commit on a feature branch, merged fast-forward into main.
The architecture I'm happiest with is the dual-path matching in the response handler:
$caseId = null;
if ($outboundRecord) {
// Path A: outbound record exists
$outboundRecord->update([...]);
$caseId = $outboundRecord->case_id;
}
// Path B: no outbound record — match by identifier
if (!$caseId) {
$coreId = substr($identifier, 0, 10);
$record = Record::where('reference', $coreId)->first();
if ($record) {
$case = $record->onboardingCases()
->whereIn('status', ['open', 'in_progress'])
->first();
if ($case) {
$caseId = $case->id;
}
}
}
It's simple, handles both scenarios, and doesn't break the existing flow.
What I Learned
-
Custom Blade directives have strict casing rules on closing tags.
Blade::if('canPermission', ...)gives you@canPermission/@endcanPermission. Theendprefix is always lowercase. Get one letter wrong and you get a cryptic parse error that points to a completely different line. -
The parse error never points to the actual problem. Blade said line 450 (
@endforeach) was wrong. The actual bug was on line 353 and 446 (@endCanPermission). When Blade can't parse a custom directive, it silently skips it, and the error surfaces wherever the nesting finally breaks down. -
Adding routes to a new middleware group is easy to forget. I changed the view checks from admin to permission-based, but the form actions still pointed to admin-only routes. The UI would show the buttons, but clicking them would 403. You need to add the routes AND update the view references.
-
Backfill commands are worth building upfront. Every feature that changes how data flows needs a command to fix the existing data. I've built enough of these now that there's a pattern: query the gap, match by identifier, update, sync status. The admin utility command system means these can be run from the UI without needing server access.
-
Eager-loading grouped by ID is a clean pattern for card views. Instead of N+1 queries inside the loop, one query with
->groupBy('case_id')gives you a keyed collection you can index directly in the view:$records[$case->id] ?? collect().
Tech Notes
- Stack: Laravel 12, PHP 8.4, MySQL, Blade templates (no Tailwind/Vite CSS)
- Architecture: nwidart/laravel-modules v12 — 18 domain modules
- Key pattern:
Blade::if()for custom permission directives — closing tag is always@end{name}with lowercaseend - Commands added: Two backfill artisan commands, registered as admin utility commands with "Run Now" UI
- No migrations needed — all changes were to existing tables/columns and application logic
Zero Focus Dev
Breaking things, learning fast, sharing it all.