Skip to main content

Since October 2025, and Laravel v11.3.0, Laravel has shipped with a composer script that allows you to run the command composer run dev, to immediately start serving requests for your Laravel application locally. It was a useful addition, but it uses the artisan serve command, amongst other things, which is great, unless you're using Laravel Sail. Whenever I load up a project that happens to use Sail, I end up having to open multiple terminal tabs to run the various commands.

"Why not just replace the commands with sail commands?"

You can replace the various parts with Sail-specific commands, and it'll run, but the issue comes when you end the process because it'll exit before Sail stops all the containers. There is a solution for this, but before we get to that, let's look at what's actually happening, and why.

interested in all the details, you can jump ahead to the solution. {% /skip %}

The Command

The dev script/command actually consists two entries, with the second consisting of multiple commands. The first entry is a call to Composer\Config::disableProcessTimeout (code here), which removes the command timeout that would typically cause the process to exit early. The second entry is a command that is a little more complex.

npx concurrently -k -c "#93c5fd,#c4b5fd,#d4d4d8,#fdba74" "php artisan serve" "php artisan queue:listen --tries=1" "php artisan pail" "npm run dev" --names=server,queue,logs,vite

Unescaped Commands

This and all other commands in this article are unescaped, appearing as they would in a shell. When adding them to a script/command within the composer.json file, you'll need to escape the double quotes (").

Let’s look at what this command actually is, which will be useful because I know that the command itself is longer than the HTML element will display.

First up we have npx, which is a command that has come pre-bundled with npm since 5.2.0, and lets you execute a package (it stands for node package exectue). One of the biggest benefits, depending on whom you ask, is that the package that you're executing doesn't have to be installed locally.

The package that we're running here is called concurrently, and it's an npm package that, as the name and context suggest, lets you run multiple commands concurrently.

It's being used with three arguments, two at the start and one at the end.

  • -k Tells it to kill all other processes once the first one exits.
  • -c "#93c5fd,#c4b5fd,#d4d4d8,#fdba74" Tells it the colours to use for the processes.
  • --names=server,queue,logs,vite Gives each process a name.

Finally, we have the four commands that are actually being run.

Server

The command named server and coloured #93c5fd is the artisan serve command, which uses PHP built-in development server to serve the application.

php artisan serve

This is the first of the commands, which means that all other processes will be killed as soon as this one has exited.

Queue

The command named queue and coloured #c4b5fd runs Laravels queue listener. It uses the --tries argument, which tells it to only attempt a job once before treating it as a failure.

php artisan queue:listen --tries=1

Logs

Next up is the logs command with the colour #d4d4d8 which runs Laravel Pail, a simple development package that tails the Laravel logs for you.

php artisan pail

Vite

Last but not least, we have the npm script called dev which runs Vite to process all the frontend assets.

npm run dev

Using Sail

If you're using Laravel Sail, your first thought may be to replace the relevant commands with sail ones, making the four commands the following:

./vendor/bin/sail up
./vendor/bin/sail artisan queue:listen --tries=1
./vendor/bin/sail artisan pail
npm run dev

Which will work, at least, initially. The problem here is the way that concurrently exits. It'll kill the processes before docker is able to perform its teardown operations. If you run sail up on its own, and hit ctrl+c , you'll notice that it won't exit straight away, but will instead exit each container one by one. When run in this command using concurrently, you won't see any output, but if you check with docker, most, if not all of that projects containers will still be running.

So, to use this command effectively, we need a way to detect that the process is exiting, and perform the necessary cleanup, which would be sail stop (or sail down if you like it being destructive).

Trapping the Signal

Luckily, if you're using a POSIX-compliant shell, which to be honest is pretty much all of them as long it's not Windows, there's a builtin shell command called trap (man pages here) which lets you run something for a signal.

Signals?

If you're not familiar with signals, they're essentially a way to notify a process that something happened. They're used to tell a process they've been interrupted, terminated, killed, etc.

This means that the sail up command needs to be two commands in one, with the first using trap.

trap "./vendor/bin/sail stop" EXIT SIGTERM SIGINT SIGHUP; sail up

The only downside is that it will be caught multiple times because the teardown and cleanup will cause multiple signals. If you can live with the docker containers being stopped something like four times, then you can use the below command.

npx concurrently -k -c "#93c5fd,#c4b5fd,#d4d4d8,#fdba74" "bash -c 'cleanup() { ./vendor/bin/sail stop; }; trap cleanup EXIT SIGTERM SIGINT SIGHUP; ./vendor/bin/sail up'" "./vendor/bin/sail artisan queue:listen --tries=1" "./vendor/bin/sail artisan pail" "npm run dev" --names=server,queue,logs,vite

As you can probably appreciate, this looks a bit messy, and it's not ideal that it's stopping containers multiple times. But fear not, there is a better solution.

The Solution

The ideal way to handle this is to create yourself a dev.sh script in the root of your project, and change the command to the following:

npx concurrently -k -c "#93c5fd,#c4b5fd,#d4d4d8,#fdba74" "bash dev.sh" "./vendor/bin/sail artisan queue:listen --tries=1" "./vendor/bin/sail artisan pail" "npm run dev" --names=server,queue,logs,vite

Then put this in dev.sh.

#!/bin/bash

DESTROY=${DESTROY:-false}

cleanup() {
    trap '' EXIT SIGTERM SIGINT SIGHUP

    echo ""
    if [ "$DESTROY" = "true" ]; then
        echo "Stopping and removing containers..."
        ./vendor/bin/sail down
    else
        echo "Stopping containers..."
        ./vendor/bin/sail stop
    fi
}

trap cleanup EXIT SIGTERM SIGINT SIGHUP

echo "Starting Sail..."
./vendor/bin/sail up

You can probably tell that I've added a bit of polish to this, with messages explaining what's happening and the likes, even going as far as allowing you to choose between stopping the containers or destroying them. However, there are two very important bits.

The first is this.

trap cleanup EXIT SIGTERM SIGINT SIGHUP

This tells the shell to run the cleanup function when it receives the following signals.

  • EXIT This is a sort of pseudo signal that detects that a process is about to exit, rather than it is exiting.
  • SIGTERM A "please shut down" signal, the kind sent by the kill command.
  • SIGINT This is an interupt signal, typically sent by pressing ctrl+c .
  • SIGHUP This is a hangup signal, typically sent by the shell when it is closed, like if you close your terminal.

The second important bit is inside the cleanup function.

trap '' EXIT SIGTERM SIGINT SIGHUP

This essentially resets the other trap, so when running cleanup the signals aren't caught multiple times.

Using the Script

Update your composer.json file so the dev script is defined as follows:

{
  "dev": [
    "Composer\\Config::disableProcessTimeout",
    "npx concurrently -k -c \"#93c5fd,#c4b5fd,#d4d4d8,#fdba74\" \"bash dev.sh\" \"./vendor/bin/sail artisan queue:listen --tries=1\" \"./vendor/bin/sail artisan pail\" \"npm run dev\" --names=server,queue,logs,vite"
  ]
}

Once you've got it all set, you can run the command like normal. If you want to destroy the containers instead, you set the DESTROY environment variable when running the command.

DESTROY=true composer run dev

And there you have it! You can now run composer run dev and it'll work with sail.