/

Laravel 10 in an internal app: native types, the Process facade, and Pennant

2,172 words, about 10 min read

The upgrade guide puts a number at the top of the page: Estimated Upgrade Time: 10 Minutes. Our internal ops and finance app took nine hours spread over two evenings, and five of those hours went into two changes composer update cannot see because nothing throws until the code runs. We are on Laravel 10.9.0 now, three months after 10.0 landed on 14 February. The customer-facing platform is still Symfony 6.2 and none of this touches it.

For context on scale: the app serves dispatch, support and finance, a small group maintains it, and it has 311 test cases. Small app, real money moving through it. That combination is why I read every line of the upgrade guide instead of running Shift and hoping.

Ten minutes on paper, nine hours in practice

The dependency part really is fast. Laravel 10 needs PHP 8.1 and Composer 2.2, both of which we already had since the 2022 platform work, so the language bump was free. The upgrade guide lists the constraint changes and they went in as one commit: laravel/framework to ^10.0, laravel/sanctum to ^3.2, doctrine/dbal to ^3.0, spatie/laravel-ignition to ^2.0, nunomaduro/collision to ^7.0, phpunit/phpunit to ^10.0. We also deleted minimum-stability from composer.json since the default is stable anyway, and removed the processUncoveredFiles attribute from the <coverage> block in phpunit.xml, which PHPUnit 10 no longer accepts.

Then the two things that cost the evening.

The first was the removal of Eloquent’s deprecated $dates property. This one is mean because it fails silently. Nothing warns you, the property is simply ignored, and the attribute comes back as a string. Our Remittance model had carried protected $dates = ['settled_at', 'reconciled_at']; since 2019. After the update, the finance statement page died with Error: Call to a member function format() on string at app/Support/Statements/PeriodFormatter.php:48. Easy fix, move the keys into $casts as datetime, but we had 23 models and had to read all of them, because a passing test suite proves nothing here: most of our factories were writing dates that happened to render fine as raw strings in Blade. Two models were only caught by a support ticket on the Thursday.

The second was database expressions. Expressions are rewritten in 10.x and the raw string now comes out of getValue(Grammar $grammar); casting with (string) is gone, and the class no longer has a __toString at all. Our courier payout report built a subquery alias by string-concatenating a DB::raw, which is exactly the pattern the guide calls out as atypical. It threw Object of class IlluminateDatabaseQueryExpression could not be converted to string. The replacement is one line:

$expression = DB::raw('sum(amount_centavos) / 100');

$sql = $expression->getValue(DB::connection()->getQueryGrammar());

Everything else in the guide was a no-op for us. We never used Bus::dispatchNow, Redirect::home, or the MocksApplicationServices trait with its expectsEvents helpers. We do use Cache::tags(), but on Memcached, which is the only place the docs recommend it.

The last exec() in the codebase

The app generates two kinds of paper: a PDF remittance statement for finance and a batch of shipping labels for dispatch. Both went out through a shell, and the calling code had been untouched since 2020 because nobody wanted to own it.

$cmd = sprintf(
    '%s --quiet %s %s 2>&1',
    config('pdf.binary'),
    escapeshellarg($htmlPath),
    escapeshellarg($pdfPath)
);

exec($cmd, $output, $status);

if ($status !== 0) {
    throw new LabelRenderFailed(implode("n", $output));
}

The Process facade replaced that with something I can read in one pass. It is a wrapper over the Symfony Process component (the framework requires symfony/process at ^6.2), so there is no new dependency and no new behaviour underneath, just an API that does not make me think about 2>&1:

use IlluminateSupportFacadesProcess;

$result = Process::timeout(180)
    ->path(storage_path('labels'))
    ->run([config('pdf.binary'), '--quiet', $htmlPath, $pdfPath]);

if ($result->failed()) {
    throw new LabelRenderFailed($result->errorOutput());
}

Passing an array means no escapeshellarg and no interpolation, which is the actual win. The default timeout is 60 seconds and a ProcessTimedOutException is thrown past it; our 400-label batches take around 90 seconds on the finance box, so the explicit timeout(180) is load-bearing rather than decoration.

Testing is where this paid for itself. We used to have a ShellRunner interface with a null implementation purely so tests would not spawn binaries. That whole class is deleted. The test now reads:

use IlluminateProcessPendingProcess;
use IlluminateSupportFacadesProcess;

public function test_label_batch_renders_one_pdf_per_booking(): void
{
    Process::preventStrayProcesses();

    Process::fake([
        '*--quiet*' => Process::result(output: '', exitCode: 0),
    ]);

    $this->actingAs($this->dispatcher())
        ->post('/labels/batch', ['bookings' => [1200341, 1200342]])
        ->assertOk();

    Process::assertRanTimes(
        fn (PendingProcess $process) => is_array($process->command),
        times: 2
    );
}

Two things about that snippet took me longer than they should have. Fake patterns are matched against the Symfony command line, not against what you passed in, so with an array command the pattern has to match the escaped and quoted string. And Process::assertRan('some command') with a string argument compiles down to a strict comparison on $process->command, which is an array in our case, so it can never match. I stared at An expected process was not invoked. for a while before reading the source. Use a closure when you pass array commands. preventStrayProcesses() is worth turning on globally too: any unfaked process throws Attempted process [...] without a matching fake. instead of quietly shelling out on a CI runner that has no PDF binary installed.

Pennant, and the flag that was off when it should have been on

Pennant is a separate first-party package, not part of the framework, and it is on 1.2.1 as of late March. Install is composer require laravel/pennant, publish, migrate, and you get a features table.

Our use case was the rewritten remittance statement screen. Finance wanted it, support did not want to retrain during the April cutoff week, and the old screen had to stay reachable. So:

use AppModelsUser;
use LaravelPennantFeature;

Feature::define('statements-v2', fn (?User $user) => match (true) {
    $user === null => true,
    $user->isInDepartment('finance') => true,
    default => false,
});

The ?User and the null arm are there because of the gotcha, and the gotcha cost us a night of statements. If the scope is null and the definition does not accept null, Pennant returns false without ever calling your closure. It checks the first parameter’s type by reflection and short-circuits. Our nightly statements:generate command runs under the scheduler with nobody authenticated, so the default scope was null, so the flag read as inactive, so 1,100 statements generated on the old template hours after we had switched finance over in the UI. The documentation says this plainly under Nullable Scope. I had read that page. I read it as an edge case for public routes and not as a description of every queued job and Artisan command we own.

The other thing to know is that resolved values are stored, not recomputed. Once a feature has been checked for a scope, that row is the answer, and editing the closure changes nothing for users who already have a value. You either roll forward with Feature::activateForEveryone('statements-v2') or clear storage with Feature::purge('statements-v2'). The pennant:purge Artisan command in 1.2.1 accepts feature names and a --store option, nothing more, so purging selectively is a job for the deploy script. We put the purge in ours after changing a definition twice and wondering why staging disagreed with itself.

Would I have reached for a package for one boolean? Probably not. But we had three of these switches in flight by the end of April, all of them previously a column on users plus a helper method nobody trusted, and the per-user rollout with a stored decision is genuinely the thing we were badly reimplementing.

Native types in the skeleton, when you already run static analysis

The headline change in the release notes is argument and return types across the application skeleton and every generator stub, with the old doc block type hints deleted. Adopting it in an existing app is optional and backwards compatible, and the 9.x to 10.x diff is mostly this.

We did it anyway, over about 90 minutes, and I am lukewarm on the value for the files we already had. We run PHPStan at level 5 in CI. Adding : ?string to redirectTo() in app/Http/Middleware/Authenticate.php taught the analyser nothing it did not already infer from the doc block, and the diff was 340 lines of pure churn that made two later code reviews harder to read than they needed to be.

Where it does matter is the stubs. Every class our team generates from now on starts typed, which removes the small daily argument about whether to bother. And it means the framework’s own signatures are the documentation, which is better than a doc block that drifted. If you are not running static analysis at all, the skeleton types are probably the cheapest way to start getting something out of it. If you are, treat the churn as optional and do it in a commit of its own so reviewers can skip it.

The validation rule generator changed shape in the same spirit. make:rule no longer has an --invokable option, because that is the default now, and the generated class implements IlluminateContractsValidationValidationRule:

public function validate(string $attribute, mixed $value, Closure $fail): void
{
    if (! preg_match('/^PDL-[0-9]{7}$/', $value)) {
        $fail('The :attribute is not a valid booking reference.');
    }
}

Our four old rule classes with passes() and message() still work, so this was not forced. We converted two of them, kept the other two, and the inconsistency annoys me every time I open that directory.

What we skipped, and what –profile said

We skipped the Pest scaffolding entirely. It is an installer flag (laravel new example-application --pest) for new applications, and rewriting 311 PHPUnit tests is not a project. We skipped publishing the lang directory, since ours already exists and the change only affects fresh installs. We renamed $routeMiddleware to $middlewareAliases in app/Http/Kernel.php because the skeleton did, although the guide is explicit that it is optional.

One change I did not ask for and now rely on: the make commands no longer require arguments. Run php artisan make:controller with nothing after it and it prompts for the name. That behaviour comes from GeneratorCommand implementing the PromptsForMissingInput contract, so it applies to every generator at once, including the ones our own packages define by extending that class. Minor. Still the thing our newest dev noticed first.

The --profile flag on php artisan test is the small thing I use most. It lists the ten slowest tests. One detail: it lives in Collision 7’s Laravel adapter rather than in the framework itself, so it arrived with the dev dependency bump. Our slowest test was 14.2 seconds: a feature test that rendered a full month of remittance PDFs through the real binary, which is precisely the thing Process::fake() now replaces. The suite went from 4m 51s to 3m 08s after faking the four tests --profile pointed at. That is CI minutes we were burning for no coverage.

Laravel 9 gets bug fixes until 8 August and security fixes until February 2024, so we were not actually in a hurry, and I would have waited another month if the statement rewrite had not wanted a flag. What is still open: the features table has 1,847 rows after four weeks and there is no story yet for pruning the rows of staff who have left, and I have not decided whether the nightly generator should pass an explicit scope or whether every definition in the app should just declare a nullable first parameter and a default. Right now we have one of each, which is the worst answer.

Sources

  • Laravel 10 release notes: PHP 8.1 minimum, the 14 February 2023 release date, skeleton and stub types, Pennant, the Process facade, --profile, the installer’s --pest flag, generator prompts, and the support window for 9.x and 10.x.
  • Upgrade guide, 9.x to 10.0: the ten minute estimate, dependency constraints, minimum stability, the $dates removal, database expressions and getValue(), Monolog 3, PHPUnit 10’s processUncoveredFiles, the $middlewareAliases rename, and the removed Bus::dispatchNow, Redirect::home and MocksApplicationServices.
  • Process documentation: run, path, the 60 second default timeout, ProcessTimedOutException, Process::fake, Process::result, the assertions, and preventStrayProcesses.
  • IlluminateProcessFactory at v10.0.0: why a string argument to assertRan cannot match an array command, since it becomes a strict comparison against $process->command.
  • Pennant documentation: installation, Feature::define and Feature::active, nullable scope returning false, stored values, activateForEveryone, and purge.
  • pennant:purge in v1.2.1: the command signature, which is feature names plus --store and nothing else.
  • laravel/pennant releases: 1.0.0 on 14 February 2023 and 1.2.1 on 28 March 2023, the current version as I write this.
  • The rule stub at v10.0.0: the generated class implements ValidationRule with a typed validate() method.
  • Collision v7.0.0 TestCommand: --profile lists the ten slowest tests and ships in Collision, not the framework.
  • laravel/laravel 9.x to 10.x comparison: the skeleton diff, most of which is the native type adoption.