Back to blog
laravel ai-assisted-development claude-code cherry-pick debugging

Cherry-Picks, Null Crashes, and Wrong API Codes — One Session, Three Layers of Broken

The Starting Point

I'm building a SaaS platform that integrates with an external industry API. The system already handles onboarding, data submissions, and message processing. What it didn't have was a new data capture feature that needed to work across four different user-facing surfaces — admin panel, entity detail page, customer portal, and CSV bulk import — all sharing one backend.

The feature also needed to create API records for export to the external system, handle responses back, and enforce business rules about which combinations of options are valid (think: if you pick type A, you can only use methods X and Y — but type B allows X, Y, and Z).

What Actually Happened

Cherry-picking across repos — the landmine factory

Before starting the main feature, I needed to bring in some enhancements from a separate deployment (a different repo that shares the same codebase). One commit, git cherry-pick f83b6e9.

Four merge conflicts. All the same pattern — HEAD had nothing, the incoming commit had new code. Straightforward to resolve. But here's where the first problem was planted: the cherry-pick brought in a route and a view that calls it via AJAX — but the actual controller method wasn't in that commit. It lived in a different branch that never made it across.

I didn't notice this until later.

The missing controller method

Went to test the feature. Clicked a lookup button, got:

Failed to lookup. Please try again.

The route existed. The JavaScript existed. The controller method? Nowhere to be found. The cherry-pick brought over the route registration and the Blade template, but the PHP method that handles the request was in a different commit entirely.

Built it from scratch based on what the JS expected — it calls a service method and returns JSON. Simple enough once you know what's missing.

The JavaScript crash nobody asked for

Fixed the controller method, tried again. Same error message: "Failed to lookup. Please try again."

But this time the first field did populate. The AJAX call was succeeding, the data was coming back, fields were being filled in... and then it crashed.

The culprit:

if (data.address_line_2) document.getElementById('addressLine2').value = data.address_line_2;

There was no element with id="addressLine2". The cherry-picked form had Address Line 1, City, and Postcode — but no Address Line 2 field. The cherry-picked JavaScript expected it. document.getElementById('addressLine2') returned null, setting .value on null threw, and the promise chain fell into .catch() which displayed the generic error message.

The AJAX call had actually succeeded. The address was populated. But because the JS crashed after filling in the fields, it showed a failure message. Completely misleading.

The fix was two things: null checks on the JS (&& document.getElementById('addressLine2')) and adding the missing form field. The kind of bug that makes you feel stupid for 30 seconds and then grateful you found it.

Picking the wrong API transaction type — three times

The main feature needed to submit data through an external API. The API has ~100 transaction types, each carrying different data fields. I needed to find the right one.

I dug into the API's Excel template programmatically using PhpSpreadsheet:

$reader = IOFactory::createReader('Xlsx');
$reader->setLoadSheetsOnly(['Transactions']);
$spreadsheet = $reader->load($templatePath);

Listed all transaction types. Found one that seemed right based on the naming — "TX-100". Checked its data fields:

TX-100 fields: F01, F02, F03

Only three fields. That's an identifier, a status field, and a comment. Not enough to carry the actual data — no value, no serial number, no date.

I went with it anyway, storing extra fields in a JSON blob "for reference." Built the whole thing. Created an API record. Exported it.

Then the client said: "Why did you use TX-100 when it should be TX-200?"

They were right. TX-200 had all thirteen fields we needed. I had to update the service, the response matching logic, the view text, AND fix the record that had already been created with the wrong type:

$record = ApiRecord::find(2);
$record->update(['transaction_type' => 'TX-200', ...]);
$record->update(['status' => 'pending', 'exported_at' => null]);

Lesson: read the spec, not the label. The naming convention made TX-100 sound right. The actual field content told a different story.

The npm run build surprise

After getting everything working: npm run build — vite: command not found. Node modules weren't installed on the dev environment. npm install first, then build. Not a real problem, but the kind of thing that eats five minutes when you're in the zone.

The migration rollback noise

When I re-ran a migration to pick up schema changes, migrate:refresh tried to roll back the entire migration history and spat out 90+ "Migration not found" warnings. The actual migration worked fine — just one table dropped and recreated. But the output was alarming if you didn't know to ignore it.

What Worked

The final feature spans 20 files and 3,065 lines:

The model enforces business rules with a constant map:

const ALLOWED_METHODS = [
    self::TYPE_INITIAL => [self::METHOD_VISUAL],
    self::TYPE_FINAL => [self::METHOD_VISUAL, self::METHOD_ESTIMATED],
    self::TYPE_DISCONNECTION => [self::METHOD_VISUAL, self::METHOD_REMOTE],
    self::TYPE_CYCLIC => [self::METHOD_VISUAL, self::METHOD_CUSTOMER, self::METHOD_REMOTE],
    // ...
];

The JS dynamically filters a dropdown based on the selected type. Pick "Initial" and your only option is "Visual". Pick "Cyclic" and you get three choices. Business rules enforced at both the model and UI layer.

One service, four controllers. Admin panel, entity detail tab, customer portal, and CSV import all share DataCaptureService. The service handles validation, API record creation, and external system integration. Each controller just handles auth, request validation, and view rendering. Zero duplicated logic.

The API integration was generic by accident. The existing export service already handles any transaction type by reading column mappings from the template dynamically. Adding a new transaction type meant zero changes to the export pipeline — it just worked.

Portal scoping. Portal users can only see and submit data for entities belonging to their linked accounts. CSV imports validate each row against the same authorization chain. The scoping logic is one Eloquent query with nested relationships.

What I Learned

  • Cherry-picks across repos are landmines. The code that makes it across might reference things that don't. Routes without controller methods. JavaScript that targets DOM elements from a form that wasn't included. Always test the full flow after a cherry-pick, not just "does it compile."

  • document.getElementById() returning null doesn't throw — setting a property on null does. And when it happens inside a .then(), the error gets swallowed by .catch() which shows a completely misleading message. The user sees "Failed" when the operation actually succeeded. Always null-check DOM lookups.

  • Read the spec, not the label. I assumed the right API transaction based on naming. The actual data field content told a different story — the one that sounded right only carried 3 fields, the one I needed had 13. Check what a thing actually contains before building on it.

  • migrate:refresh --path= rolls back everything, not just the specified migration. The --path flag only applies to the "migrate up" part. The rollback goes through the entire history. Harmless but noisy.

  • One service, many controllers is the right pattern for multi-surface features. When four different UIs need the same business logic, put it in a service. Each controller is thin — just auth, validation, and view rendering.

  • AI pair programming catches patterns but misses domain knowledge. Claude nailed the architecture, the CSV import pattern (copied from an existing service), the portal scoping, the response matching. But it picked the wrong API transaction type because it didn't know the domain conventions — that took a human who works in the industry to spot.

Tech Notes

  • Stack: Laravel 12, PHP 8.2, nwidart/laravel-modules, PhpSpreadsheet
  • Pattern: Service layer shared across 4 controllers (admin, detail, portal, CSV)
  • Business rules: Constant maps for valid type/method combinations, enforced at model + JS
  • API integration: Generic export pipeline reads field mappings from template — zero changes needed for new transaction types
  • Commands worth remembering:
    • php artisan tinker --execute="..." for quick data fixes when you pick the wrong transaction type
    • Reading API templates programmatically via PhpSpreadsheet to discover field mappings
    • migrate:refresh --path= only scopes the "up", not the rollback
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.