Running Ignition on five years of Laravel
Here at Flare, we're trying to make the best Laravel error tracker possible, so we support as many Laravel versions as possible.
Flare and thus Ignition run on Laravel versions 6 (released in 2019) up to Laravel 11 (when writing the most recent version). Ignition is the default error page in Laravel 6, and we've completely rewritten it with Laravel 9 and added a new design.
The problem? When making changes to Flare or packages like spatie/backtrace, which powers the stack traces used by Ignition, how can we check that everything works when something changes?
Of course, we have continuous integration testing for this; the Ignition packages are tested with each commit and PR to ensure nothing breaks. Eventually, these packages will end up in a Laravel application for our Flare customers and everyone using Laravel. How can we make sure that they work with our install instructions? What if we add some new visual features to Ignition? How do we check for each Laravel version that the feature works? In these cases, we can wait for CI, but quickly checking something is just so handy!
A nasty small bug
We created a unique structure to set up Ignition and Flare in Laravel versions 6 to 11. The reason we did this was the following bug:
Notice that the serializable closure is defined twice: once where it belongs in the code view and once as the filename, causing problems in Ignition. In this case, it is a small closure not causing too much trouble. But with bigger closures, Ignition becomes unusable, and Flare doesn't know what to do with the long filename.
This error can only be triggered using a serializable closure, a solution from Laravel that allows closures in jobs to be serialized. Simply said, the serializable closure package will keep a copy of the closure's code as a string and its state. Then, when unserialized, the code will be executed by including a stream of the string of the code (PHP magic at its best!).
Since we're not executing a file with code but a stream, there is no filename, and thus, the stream of code is used as a filename. Our code is floating in thin air between files. This is weird stuff, indeed.
The fix is to set the filename to laravel-serializable-closure://function()
, and we also clean up the code example:
But how do we check if this works for all Laravel versions since this is a more visual bug?
Building a test app(s) repository
We've got an internal repository called flare-test-apps
containing a Vue, React, PHP, and, of course, a Laravel app. We removed that last one and replaced it with 6 new Laravel apps, which require versions 6 through 11.
The next thing to do was to get them working simultaneously. Laravel 6 requires a minimum PHP version of 8.0, while Laravel 11 requires at least PHP 8.2. Luckily, we've got an excellent tool for this: Laravel Valet!
Laravel Valet is not only a server that can serve your local Laravel application but also can run CLI commands. The cool thing about Laravel Valet is that it can isolate sites, meaning that we can set a specific PHP version that will be used to serve the site and also run CLI commands.
To set this up, we loop using a bash script over all the laravel-*
directories (where * is the Laravel version):
#!/bin/bash
for dir in laravel-*; do
if [[ -d "$dir" && ${dir#laravel-} =~ ^[0-9]+$ ]]; then
cd "$dir"
php_requirement=$(grep -o '"php": "[^"]*' composer.json)
php_versions=$(echo $php_requirement | tr -dc '0-9.|\n')
if [[ $php_versions == *"|"* ]]; then
IFS='|' read -ra versions <<< "$php_versions"
php_version=$(printf '%s\n' "${versions[@]}" | sort -V | tail -n 1)
else
php_version=$php_versions
fi
echo "Linking $dir with PHP version $php_version"
valet link $dir.flare-test-apps
isolated_sites=$(valet isolated)
if ! echo "$isolated_sites" | grep -q "$dir"; then
echo "Isolating site $dir"
valet isolate php@$php_version --site=$dir.flare-test-apps
fi
valet composer update
fi
cd ..
done
I must admit, chatGPT was quite helpful in writing this BASH code. Let's go quickly over it:
- We loop over each laravel-* directory
- We go into the directory
- Check which php version was required by Composer
- This could be
^7.4|^8.0
. We'll always take the highest version to reduce the amount of PHP versions required on our computers
- This could be
- Then we'll tell Laravel valet that the subdomain laravel-*.flare-test-apps.test should serve the current directory by using the
valet link command
- We'll check if the site is isolated at the moment by checking the
valet isolated
command if the current directory is isolated- If not, we'll isolate it using the PHP version found earlier and the
valet isolate
command
- If not, we'll isolate it using the PHP version found earlier and the
- We run
valet composer update
, which will run composer update in the directory - And we return back to the root dir so that we can visit other laravel-* directories
From now on, we'll have a working version of all the required Laravel versions. You can check this by going to http://laravel-6.flare-test-apps.test or http://laravel-10.flare-test-apps.test.
Adding common routes
To check our serializable closure bug, we'll need a route and controller to trigger it. The code is simple, but copying and pasting it into every Laravel version directory would be stupid.
Since we at Spatie (the creators of Flare) are masters at creating packages, let us create yet another one! Albeit one that will never be published on Packagist, we call it laravel-shared. It will contain the route required for testing.
The package will live in the laravel-shared directory, and its composer file looks like this:
{
"name": "spatie/laravel-shared",
"require": {
"php": "^7.4|^8.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"laravel/serializable-closure": "^1.3"
},
"autoload": {
"psr-4": {
"Spatie\\LaravelShared\\": "src"
}
},
"extra": {
"laravel": {
"providers": [
"Spatie\\LaravelShared\\LaravelSharedServiceProvider"
]
}
},
"minimum-stability": "stable"
}
Nothing too fancy, we register a LaravelSharedServiceProvider
looking like this:
<?php
namespace Spatie\LaravelShared;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Spatie\LaravelShared\Controllers\FlareController;
class LaravelSharedServiceProvider extends ServiceProvider
{
public function boot()
{
Route::get('serializeable-closure', [FlareController::class, 'serializeableClosure']);
}
}
The service provider will register a route (like you would do in routes/web.php
) to a controller where the bug will be triggered.
Within every Laravel application, we add a dependency for the laravel-shared
package as such:
"require" : {
"php": "^8.2",
"laravel/framework": "^11.0",
"laravel/tinker": "^2.9",
"spatie/laravel-shared": "@dev"
},
"repositories" : [
{
"type" : "path",
"URL" : "../laravel-shared"
}
],
And that's it! But how can we now check this new route by typing in 6 URLs? We're developers, let's automate this by reusing the BASH script we wrote before:
#!/bin/bash
if [ -z "$1" ]; then
echo "No command provided, the following commands can be used:"
echo " setup: Setup all Laravel applications"
echo " open: Open a URL on all Laravel applications"
exit 1
fi
function setup_laravel() {
# Our previous code
}
for dir in laravel-*; do
if [[ -d "$dir" && ${dir#laravel-} =~ ^[0-9]+$ ]]; then
cd "$dir"
echo "Working in $dir"
if [ "$1" == "setup" ]; then
setup_laravel
fi
if [ "$1" == "open" ]; then
open -a "Safari" "http://$dir.flare-test-apps.test/$2"
fi
cd ..
fi
done
Now, when running our script like this:
./laravel.sh open serializable-closure
Six browser tabs will open, each with a different Laravel version calling the route.
Adding Flare support
Wouldn't it be cool to test whether the Flare client works on each Laravel version by triggering php artisan flare:test
?
To enable Flare in a Laravel application basically, three steps are required:
- Add a Flare key to the
flare.key
config entry (in regular applications, we'll check theFLARE_KEY
environment variable for this) - Add a Flare logging channel to the
logging.channels
config entry - Update the
logging.channels.stack.channels
config entry also to include the logging channel we've added
We can update the LaravelSharedServiceProvider
in our laravel-shared
package as such:
<?php
namespace Spatie\LaravelShared;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Spatie\LaravelShared\Controllers\FlareController;
class LaravelSharedServiceProvider extends ServiceProvider
{
public function boot()
{
Route::get('serializeable-closure', [FlareController::class, 'serializeableClosure']);
}
public function register()
{
$flareKey = 'some-flare-api-key-here';
if($flareKey){
config()->set('flare.key', $flareKey);
config()->set('logging.channels.flare', [
'driver' => 'flare',
]);
config()->set('logging.channels.stack.channels', ['single', 'flare']);
}
}
}
This approach will work for the later versions of the Ignition client, but before Laravel 9, the Flare package (which will use the key to send a test) gets registered before laravel-shared
. This means our update to the config will come too late, and the key will be set to null
.
We've fixed this by adding the following lines to app/bootstrap.php
in those versions:
$app->resolving(Illuminate\Foundation\Bootstrap\RegisterProviders::class, function () use ($app) {
$app->register(LaravelSharedServiceProvider::class);
});
This line of code will cause Laravel to register the LaravelSharedServiceProvider
before resolving all other service providers so that the key is set correctly.
Now we're ready to test the php artisan flare:test
command on all the Laravel versions, but we're lazy developers. Let's update our bash script again so that we can run artisan commands:
#!/bin/bash
function setup_laravel() {
# Our previous code
}
command="$1"
shift
if [ -z "$command" ]; then
echo "No command provided, the following commands can be used:"
echo " setup: Setup all Laravel applications"
echo " composer: Run a composer command on all Laravel applications"
echo " artisan: Run an artisan command on all Laravel applications"
echo " open: Open a URL on all Laravel applications"
exit 1
fi
for dir in laravel-*; do
if [[ -d "$dir" && ${dir#laravel-} =~ ^[0-9]+$ ]]; then
(
cd "$dir" || exit
echo "Working in $dir"
if [ "$command" == "setup" ]; then
setup_laravel
elif [ "$command" == "composer" ]; then
valet composer "$@"
elif [ "$command" == "artisan" ]; then
valet php artisan "$@"
elif [ "$command" == "open" ]; then
open -a "Safari" "http://$dir.flare-test-apps.test/$@"
else
echo "Invalid command: $command"
fi
)
fi
done
Now, it is possible to do the following:
./laravel.sh artisan flare:test
Which will (thanks to Laravel Valet) run php artisan flare:test
on all our Laravel versions.
In the end
We're constantly making changes to Flare to make it even better! This tiny bug fix, which will only occur on Laravel projects again, shows why Flare is the best error tracker for Laravel.