Technical case study · Automotive B2B platform
Laravel 8 to 13: modernizing a mature B2B platform without losing its behavior.
The engagement began as a server move. Once I inspected the Laravel 8 application, it was clear that moving it unchanged would leave the same maintenance problem on a new host.
- Laravel 8 → 13
- Framework path
- 698
- PHP tests
- 4,593
- Assertions
- 35
- Focused commits
Assessment
The server move exposed the actual assignment
The application ran on Laravel 8, PHP 8.0, Livewire 2, Jetstream 2, PHPUnit 9, Tailwind 2, Alpine 2, and Laravel Mix. Years of order, permission, stock, document, and integration rules had accumulated around that stack.
The final system runs Laravel 13, PHP 8.3, Livewire 4, Jetstream 5, PHPUnit 12, Tailwind 4, and Vite 8. The space between those two rows was covered by 35 small, verifiable commits rather than one large Composer update.
| State | Backend | UI | Build / Tests |
|---|---|---|---|
| Before | Laravel 8 · PHP 8.0 | Livewire 2 · Tailwind 2 | Mix · PHPUnit 9 |
| After | Laravel 13 · PHP 8.3 | Livewire 4 · Tailwind 4 | Vite 8 · PHPUnit 12 |
Protecting behavior
The tests came before the first version jump
I first reconstructed the domain model from routes, Livewire components, jobs, migrations, and production workflows. The main baseline commit added 10,423 lines across tests, factories, and supporting code. It also fixed existing bugs while the application was still on Laravel 8.
One guard paid off immediately: the suite refuses to start unless the configured database begins with test_. When cached configuration pointed at the wrong database, it stopped before RefreshDatabase and therefore before destructive work.
protected function setUpTraits()
{
$database = $this->configuredDatabaseName();
if (! str_starts_with($database, 'test_')) {
throw new RuntimeException(
"Refusing destructive tests for database [{$database}]."
);
}
return parent::setUpTraits();
}
Livewire 2 → 3 → 4
Rendering does not prove that a workflow works
Livewire was riskier than many Laravel steps. A render assertion catches neither changed hydration rules nor broken persistence. The baselines therefore mount real workflows, mutate public state, invoke the action, and assert redirects, persistence, and authorization.
Livewire::actingAs($user)
->test(AccountProfile::class)
->assertSet('primaryAccountId', $first->id)
->set('primaryAccountId', $second->id)
->call('savePrimaryAccount')
->assertHasNoErrors()
->assertRedirect('/profile');
$this->assertDatabaseHas('account_user', [
'user_id' => $user->id,
'account_id' => $second->id,
]);
This shape remained useful across Livewire 2, 3, and 4: internal implementation could change while the observable contract could not.
Dependencies
Preserve the small contract, not the abandoned package
An abandoned cart package blocked newer framework versions. Instead of cloning all its code or rewriting every checkout flow during the upgrade, the application took ownership of only the contract it actually used: instances, buyables, options, events, totals, and stored carts.
$first = Cart::instance('default')->add($article, 2, $options);
$second = Cart::instance('default')->add($article, 3, $options);
$this->assertSame($first->rowId, $second->rowId);
$this->assertSame(5, $second->qty);
$this->assertSame('6000.00', Cart::total(2, '.', ''));
That kept the migration small. Compatibility is now documented by application tests rather than tied to a package whose release cycle is outside my control.
Trust boundaries
Public component properties are user input
Livewire synchronizes state between browser and server. I therefore treated prices, quantities, and record IDs in public properties as request data. The server recomputes protected values and rechecks ownership when saving, not only when mounting the component.
Livewire::actingAs($owner)
->test(EditOrder::class, ['order' => $order])
->set("lines.{$line->id}.price", 1)
->set("lines.{$line->id}.quantity", 999)
->call('save')
->assertDispatched('saved');
$this->assertSame(1000, $line->fresh()->price);
$this->assertSame(5, $line->fresh()->quantity);
A second test revokes membership after mount and expects a 403 on save. The suite therefore covers authorization that changes between two requests.
Repeatable operations
Designing for double clicks and worker restarts
Checkout tokens prevent duplicate orders when requests are replayed. For credit delivery, a durable outbox row separately records whether document creation, archiving, and email have completed. The job is unique, blocks overlap, and claims work inside a transaction.
final class DeliverCredit implements ShouldQueue, ShouldBeUnique
{
public function middleware(): array
{
return [(new WithoutOverlapping($this->deliveryId))->releaseAfter(30)];
}
public function handle(): void
{
$delivery = DB::transaction(fn () => $this->claimForUpdate());
$this->deliverMissingSteps($delivery);
}
}
The tests also require an already reserved invoice number to remain stable during a retry. That turns a job that usually works into a repeatable, inspectable process.
Tailwind and Vite
The production build needs its own contract
Laravel Mix was replaced by Vite, while Tailwind moved through 3 before 4. Dynamically assembled classes can disappear from final CSS while every PHP test remains green. A small Node script therefore reads the real Vite manifest and checks 22 required selectors in the generated asset.
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const cssEntry = manifest['resources/css/app.css'];
const css = readFileSync(resolve(buildPath, cssEntry.file), 'utf8');
const missing = requiredSelectors
.filter(([, selector]) => ! css.includes(selector));
if (missing.length) throw new Error(formatMissing(missing));
Local icons
Less runtime discovery and fewer dependencies
The final cleanup replaced 124 external icon-component usages with local Blade components. Fifty-two SVGs were added locally; with the existing custom icon, the application now owns 53. That allowed 17 Composer packages to be removed.
$html = Blade::render(sprintf(
'<x-icons.%s class="icon-test" aria-label="%s" />',
$icon,
$icon,
));
$this->assertStringContainsString('<svg', $html);
$this->assertStringContainsString('class="icon-test"', $html);
$this->assertStringContainsString('aria-label=', $html);
The test renders every local component, checks forwarded HTML attributes, and bans the old component prefixes in application views. A cleanup becomes a lasting deployment contract.
Production migration
Staging, Mailpit, and a controlled cutover
The upgrade received an isolated staging environment on the new runtime stack. Anonymized data made domain workflows realistic to test, while Mailpit captured every outgoing message. Scheduler, queue, public entry points, six browser flows, Blade compilation, Composer audit, and the production build all ran before cutover.
After client acceptance, the team completed the data freeze, final database migration, and domain switch. I then rechecked authentication, ordering, documents, mail handoff, queue, and scheduler on the new host. The upgraded application now runs there on Laravel 13.
Looking back
What I would repeat on the next upgrade
I would again start with behavior rather than version numbers: protect domain paths and destructive test boundaries first, move one major version at a time, and return to a completely green state after every step.
Laravel itself was rarely the hardest part. Livewire state, abandoned packages, the CSS production contract, repeatable background work, and the final production path required more judgment. Application-owned contracts and small, attributable commits paid off precisely there.
Author and technical basis
Who carried out this modernization
I am Mathias Onea, a software engineer focused on Laravel, PHP, and mature business applications. For this project I handled the assessment, upgrade strategy, test architecture, security work, and frontend migration, and accompanied the move to the new server.
The examples on this page come from the completed application’s tests, jobs, migrations, and build scripts. Counts and dependency versions were checked against the final upgrade branch and its successful CI run.
- Direct project experience
- 35 focused commits from the Laravel 8 behavioral baseline through the final Laravel 13 and icon cleanup.
- Technically verified
- 698 PHP tests, 4,593 assertions, the production build, CSS contract, and six browser flows passed.
- Evidence reviewed
- Code, dependencies, and project outcomes last reviewed on July 16, 2026.