visit
⚠️ This content is outdated. It was written just a few days after Laravel Sail’s initial release and Sail has received some updates since then, and more will undoubtedly come. Please for an up-to-date version, where this guide was initially published. Thank you.
Truman continues to steer his wrecked sailboat towards the infinitely receding horizon. All is calm until we see the bow of the boat suddenly strike a huge, blue wall, knocking Truman off his feet. Truman recovers and clambers across the deck to the bow of the boat. Looming above him out of the sea is a cyclorama of colossal dimensions. The sky he has been sailing towards is nothing but a painted backdrop. (Andrew M. Niccol, )
While Sail allows us to we're interested in when creating a new Laravel application, by default it is composed of three main components: PHP, MySQL and Redis. As per the , the whole setup gravitates around two files: (which you will find at the project's root after a fresh installation) and the script (found under
vendor/bin
).These containers make up your application, and they need to be orchestrated for it to function properly. There are several ways to do this, but Laravel Sail relies on to do the job, which is the easiest and most used solution for local setups.
Docker Compose expects us to describe the various components of our application in a
docker-compose.yml
file, in YAML format. If you open the one at the root of the project, you will see a version
parameter at the top, under which there is a services
section containing a list of components comprising the ones we've just mentioned: laravel.test
, mysql
and redis
.I'll describe the
mysql
and redis
services first, as they are simpler than laravel.test
; I'll then briefly cover the other, smaller ones that also come by default with a new instal.The mysql service
As the name suggests, the
mysql
service handles the MySQL database:mysql:
image: 'mysql:8.0'
ports:
- '${FORWARD_DB_PORT:-3306}:3306'
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
MYSQL_DATABASE: '${DB_DATABASE}'
MYSQL_USER: '${DB_USERNAME}'
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
volumes:
- 'sailmysql:/var/lib/mysql'
networks:
- sail
healthcheck:
test: ["CMD", "mysqladmin", "ping"]
The
image
parameter indicates which image should be used for this container. An easy way to understand images and the difference with containers is to borrow from Object-Oriented Programming concepts: an image is akin to a class and a container to an instance of that class.Here, we specify that we want to use the tag
8.0
of the mysql
image, corresponding to MySQL version 8.0. By default, images are downloaded from , which is the largest image registry. Have a look at – most images come with simple documentation explaining how to use it.The
ports
key allows us to map local ports to container ports, following the local:container
format. In the code snippet above, the value of the FORWARD_DB_PORT
environment variable (or 3306
if that value is empty) is mapped to the container's 3306
port. This is mostly useful to connect third-party software to the database, like or ; the setup would also work without it.environments
is for defining environment variables for the container. Here, most of them receive the value of existing environment variables, which are loaded from the .env
file at the root of the project – docker-compose.yml
automatically detects and imports the content of this file. For instance, in the MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
line, the container's MYSQL_ROOT_PASSWORD
environment variable will receive the value of DB_PASSWORD
from the .env
file.volumes
is to declare some of the container's files or folders as volumes, either by mapping specific local files or folders to them or by letting Docker deal with it.Here, a single Docker-managed volume is defined:
sailmysql
. This type of volume must be declared in a separate volumes
section, at the same level as services
. We can find it at the bottom of the docker-compose.yml
file: volumes:
sailmysql:
driver: local
sailredis:
driver: local
sailmeilisearch:
driver: local
The
sailmysql
volume is mapped to the container's /var/lib/mysql
folder, which is where the MySQL data is stored. This volume ensures that the data is persisted even when the container is destroyed, which is the case when we run the sail down
command.The
networks
section allows us to specify which internal networks the container should be available on. Here, all services are connected to the same sail
network, which is also defined at the bottom of docker-compose.yml
, in the networks
section above the volumes
one: networks:
sail:
driver: bridge
Finally,
healthcheck
is a way to indicate which conditions need to be true for the service to be ready, as opposed to just be started. I'll go back to this soon.The redis service
The
redis
service is very similar to the mysql
one: redis:
image: 'redis:alpine'
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'sailredis:/data'
networks:
- sail
healthcheck:
test: ["CMD", "redis-cli", "ping"]
We pull the
alpine
tag of the for Redis () and we define which port to forward; we then declare a volume to make the data persistent, connect the container to the sail
network, and define the check to perform in order to consider the service ready.The laravel.test service
The
laravel.test
service is more complex: laravel.test:
build:
context: ./vendor/laravel/sail/runtimes/8.0
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
image: sail-8.0/app
ports:
- '${APP_PORT:-80}:80'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- mysql
- redis
- selenium
Next, it has a
build
key that we haven't seen before, which points to the that is present under the vendor/laravel/sail/runtimes/8.0
folder.are text documents containing instructions to build images. Instead of pulling and using an existing image from Docker Hub as-is, the Laravel team chose to describe their own in a Dockerfile. The first time we ran the
sail up
command, we built that image and created a container based on it.Open the Dockerfile and take a look at the first line:FROM ubuntu:20.04
This means that the tag
20.04
of the ubuntu
is used as a starting point for the custom image; the rest of the file is essentially a list of instructions to build upon it, installing everything a standard Laravel application needs. That includes PHP, various extensions, and other packages like Git or Supervisor, as well as Composer.The end of the file also deserves a quick explanation:COPY start-container /usr/local/bin/start-container
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY php.ini /etc/php/8.0/cli/conf.d/99-sail.ini
RUN chmod +x /usr/local/bin/start-container
EXPOSE 8000
ENTRYPOINT ["start-container"]
RUN chmod +x
instruction;EXPOSE 8000
doesn't do anything, apart from informing the reader that this container listens on the specified port at runtime (which actually seems wrong here, since the application is served on port 80, not 8000).Other things are happening in this Dockerfile, but the above is the gist of it. Note that this one pertains to PHP 8.0, but Laravel Sail also comes with a you can point to from the
laravel.test
service in docker-compose.yml
instead.The service also has a
depends_on
section containing the list of services whose containers should be ready prior to the Laravel application's. Since the latter references both MySQL, Redis and Selenium, theirs should be started and ready first to avoid connection errors.This is where the health checks described earlier are useful: by default
depends_on
will wait for the specified services to be started, which doesn't necessarily mean they are ready. By specifying on which conditions these services are deemed ready, we ensure they are in the right state prior to starting the Laravel application.The rest of the settings should be familiar by now, so I'll skip them.The meilisearch, mailhog and selenium services
These are the smaller services I referred to earlier; they are already documented , and . The point is they work the same way as the other ones: they pull existing images from Docker Hub and use them as-is, with minimal configuration.$ ./vendor/bin/sail up
We can ignore the whole first part of the file and focus on the big
if
statement that starts like this:if [ $# -gt 0 ]; then
# Source the ".env" file so Laravel's environment variables are available...
if [ -f ./.env ]; then
source ./.env
fi
# ...
In plain English, the
$# -gt 0
bit translates to "if the number of arguments is greater than 0", meaning whenever we call the sail
script with arguments, the execution will enter that if
statement.In other words, when we run the
./vendor/bin/sail up
command, we call the sail
script with the up
argument, and the execution gets inside the big if
statement where it looks for a condition matching the up
argument. Since there is none, the script goes all the way down to the end of the big if
, in the sort of catch-all else
we can find there:# Pass unknown commands to the "docker-compose" binary...
else
docker-compose "$@"
fi
The comment already describes what's going on – the script passes the
up
argument on to the docker-compose
binary. In other words, when we run ./vendor/bin/sail up
we actually run docker-compose up
, which is the to start the containers for the services listed in docker-compose.yml
.This command downloads the corresponding images first if necessary, and builds the Laravel image based on the Dockerfile as we talked about earlier.Give it a try! Run
./vendor/bin/sail up
then docker-compose up
– they do the same thing.Let's now look at a more complicated example, one involving Composer, which is among the packages installed by the application's Dockerfile. But before we do that, let's start Sail in to run the containers in the background:$ ./vendor/bin/sail up -d
The
sail
script allows us to run Composer commands, e.g.:$ ./vendor/bin/sail composer --version
The above calls the
sail
script with composer
and --version
as arguments, meaning the execution will enter that big if
statement again.Let's search for the condition dealing with Composer:# ...
# Proxy Composer commands to the "composer" binary on the application container...
elif [ "$1" == "composer" ]; then
shift 1
if [ "$EXEC" == "yes" ]; then
docker-compose exec \
-u sail \
"$APP_SERVICE" \
composer "$@"
else
sail_is_not_running
fi
# ...
The first line of the condition starts with
shift
, which is a Bash built-in that skips as many arguments as the number it is followed by. In this case, shift 1
skips the composer
argument, making --version
the new first argument. The program then makes sure that Sail is running, before executing a weird command split over four lines, which I break down below:docker-compose exec \
-u sail \
"$APP_SERVICE" \
composer "$@"
exec
is the way Docker Compose allows us to execute commands on already running containers. -u
is an option indicating which user we want to execute the command as, and $APP_SERVICE
is the container on which we want to run it all. Here, its value is laravel.test
, which is the service's name in docker-compose.yml
as explained in a previous section. It is followed by the command we want to run once we're in the container, namely composer
followed by all the script's arguments. These now only comprise --version
, since we've skipped the first argument.In other words, when we run:$ ./vendor/bin/sail composer --version
$ docker-compose exec -u sail "laravel.test" composer "--version"
It would be quite cumbersome to type this kind of command every single time; that's why the
sail
script provides shortcuts for them, making the user experience much smoother.Have a look at the rest of the smaller
if
statements inside the big one to see what else is covered – you'll see that roughly the same principle applies everywhere.Since Sail is just Docker, you are free to customize nearly everything about it. ()Let's see what that means in practice.
The only thing we've got access to initially is the file, but we can publish more assets with the following command, which will create a new
docker
folder at the root of the project:$ ./vendor/bin/sail artisan sail:publish
$ ./vendor/bin/sail composer require jenssegers/mongodb
mongodb/mongodb[dev-master, 1.8.0-RC1, ..., v1.8.x-dev] require ext-mongodb ^1.8.1 -> it is missing from your system. Install or enable PHP's mongodb extension
$ ./vendor/bin/sail php -m
MongoDB is not part of them; to add it, open the
docker/8.0/Dockerfile
file and spot the RUN
instruction installing the various packages:RUN apt-get update \
&& apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin \
&& mkdir -p ~/.gnupg \
&& echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf \
&& apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys E5267A6C \
&& apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C300EE8C \
&& echo "deb //ppa.launchpad.net/ondrej/php/ubuntu focal main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
&& apt-get update \
&& apt-get install -y php8.0-cli php8.0-dev \
php8.0-pgsql php8.0-sqlite3 php8.0-gd \
php8.0-curl php8.0-memcached \
php8.0-imap php8.0-mysql php8.0-mbstring \
php8.0-xml php8.0-zip php8.0-bcmath php8.0-soap \
php8.0-intl php8.0-readline \
php8.0-msgpack php8.0-igbinary php8.0-ldap \
php8.0-redis \
&& php -r "readfile('//getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer \
&& curl -sL //deb.nodesource.com/setup_15.x | bash - \
&& apt-get install -y nodejs \
&& apt-get -y autoremove \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/
It's easy to identify the block related to PHP extensions since they all start with
php8.0
. Amend the end of the list so it looks like this:php8.0-redis php8.0-mongodb \
$ ./vendor/bin/sail build
This will go through all the services listed in the
docker-compose.yml
file and build the corresponding images if they have changed, including the laravel.test
service's, whose Dockerfile we've just updated.Once it's done, start the containers again:$ ./vendor/bin/sail up -d
The command will detect that the image corresponding to the
laravel.test
service has changed, and recreate the container:That's it! The MongoDB extension for PHP is now installed and enabled. We've only done it for the PHP 8.0 image, but you can apply the same process to PHP 7.4's by updating the
docker/7.4/Dockerfile
file instead, with php7.4-mongodb
as the extension name.We can now safely import the Laravel package:$ ./vendor/bin/sail composer require jenssegers/mongodb
Its documentation contains an example configuration for Docker Compose, which we can copy and adjust to our needs. Open
docker-compose.yml
and add the following service at the bottom, after the other ones: mongo:
image: 'mongo:4.4'
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: '${DB_USERNAME}'
MONGO_INITDB_ROOT_PASSWORD: '${DB_PASSWORD}'
MONGO_INITDB_DATABASE: '${DB_DATABASE}'
volumes:
- 'sailmongo:/data/db'
networks:
- sail
The changes I've made are the following: first, I specified the tag
4.4
of the mongo
image. If you don't specify one, Docker Compose will pull the latest
tag by default, which is not good practice since it will refer to different versions of MongoDB over time, as new releases are available. The introduction of breaking changes could create instability in your Docker setup, so it's better to target a specific version, matching the production one whenever possible.Then, I declared a
MONGO_INITDB_DATABASE
environment variable for the container to create a database with the corresponding name at start-up, and I matched the value of each environment variable to one coming from the .env
file (we'll come back to those in a minute).I also added a
volumes
section, mounting a Docker-managed volume onto the container's /data/db
folder. The same principle as MySQL and Redis here applies: if you don't persist the data on your local machine, it will be lost every time the MongoDB container is destroyed. In other words, as the MongoDB data is stored in the container's /data/db
folder, we persist that folder locally using a volume.As this volume doesn't exist yet, we need to declare it at the bottom of
docker-compose.yml
, after the other ones: volumes:
sailmysql:
driver: local
sailredis:
driver: local
sailmeilisearch:
driver: local
sailmongo:
driver: local
Finally, I added the
networks
section to ensure the service is on the same network as the others.We can now configure Laravel MongoDB as per the package's . Open
config/database.php
and add the following database connection: 'mongodb' => [
'driver' => 'mongodb',
'host' => env('DB_HOST'),
'port' => env('DB_PORT'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'options' => [
'database' => env('DB_AUTHENTICATION_DATABASE', 'admin'),
],
],
Open the
.env
file at the root of the project and change the database values as follows:DB_CONNECTION=mongodb
DB_HOST=mongo
DB_PORT=27017
DB_DATABASE=laravel_sail
DB_USERNAME=root
DB_PASSWORD=root
DB_HOST
is the name of the MongoDB service from docker-compose.yml
; behind the scenes, Docker Compose resolves the service's name to the container's IP on the networks it manages (in our case, that's the single sail
network defined at the end of docker-compose.yml
).DB_PORT
is the port MongoDB is available on, which is 27017
by default, as per the .We're ready for a test! Run the following command again:$ ./vendor/bin/sail up -d
It will download MongoDB's image, create the new volume and start the new container, which will also create the
laravel_sail
database:$ ./vendor/bin/sail artisan migrate
We can push the test further by updating the
User
model so it Laravel MongoDB's Authenticable
model:<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Notifiable;
use Jenssegers\Mongodb\Auth\User as Authenticatable;
class User extends Authenticatable
{
// ...
$ ./vendor/bin/sail tinker
Psy Shell v0.10.5 (PHP 8.0.0 — cli) by Justin Hileman
>>> \App\Models\User::factory()->create();
$ docker-compose exec mongo mongo
MongoDB shell version v4.4.2
connecting to: mongodb://127.0.0.1:27017/?compressors=disabled&gssapiServiceName=mongodb
Implicit session: session { "id" : UUID("919072cf-817d-43a6-9ffb-c5e721eeefbc") }
MongoDB server version: 4.4.2
Welcome to the MongoDB shell.
For interactive help, type "help".
For more comprehensive documentation, see
//docs.mongodb.com/
Questions? Try the MongoDB Developer Community Forums
//community.mongodb.com
> use admin
switched to db admin
> db.auth("root", "root")
1
> use laravel_sail
switched to db laravel_sail
> db.users.find()
The
docker-compose exec mongo mongo
command should look familiar; earlier in the article, we looked at what the sail
script does behind the scenes, which mostly consists of translating simple sail
commands into more complex docker-compose
ones. Here, we're telling the docker-compose
binary to execute the mongo
command on the mongo
container.To be fair, this command isn't too bad and we could easily remember it; but for consistency, it would be nice to have a simpler
sail
equivalent, like the following:$ ./vendor/bin/sail mongo
To achieve this we'd need to complete the
sail
script somehow, but as it is located inside the vendor
folder – which is created by Composer – we cannot update it directly. We need a way to build upon it without modifying it, which I've summarised below:sail
script at the root of the project;if
statement with custom conditions;sail
script.If we take a closer look at the
sail
file with ls -al
, we can see that it's a symbolic link to the vendor/laravel/sail/bin/sail
file:$ cp vendor/laravel/sail/bin/sail .
Open the new copy and replace the content of its big
if
with the following, leaving the rest as-is:if [ $# -gt 0 ]; then
# Source the ".env" file so Laravel's environment variables are available...
if [ -f ./.env ]; then
source ./.env
fi
# Initiate a Mongo shell terminal session within the "mongo" container...
if [ "$1" == "mongo" ]; then
if [ "$EXEC" == "yes" ]; then
docker-compose exec mongo mongo
else
sail_is_not_running
fi
# Pass unknown commands to the original "sail" script..
else
./vendor/bin/sail "$@"
fi
fi
In the above code, we removed all the
if...else
conditions inside the big if
and added one of our own, which runs the command we used earlier to access the Mongo shell if the value of the script's first argument is mongo
. If it's not, the execution will hit the last else
statement and call the original sail
script with all the arguments.You can try this out now – save the file and run the following command:$ ./sail mongo
Try another command, to make sure the original
sail
script is taking over when it's supposed to:$ ./sail artisan
That's it! If you need more commands, you can add them as new
if...else
conditions inside the big if
of the copy of the sail
script at the root of the project.It works exactly the same way, except that you now need to run
./sail
instead of ./vendor/bin/sail
(or update your Bash alias if you created one as suggested in the ).The first one concerns the custom
sail
commands: while it's possible to extend the sail
script as demonstrated earlier, the process is a bit ugly and somewhat hacky. Sail's maintainers could fix this with an explicit Bash extension point allowing users to add their own shortcuts, or by publishing the sail
script along with the other files.Second, the Laravel application is served by PHP's development server. I won't go into too much detail here, but as mentioned before manages the PHP process in the
laravel.test
container; is where Supervisor runs the php artisan serve
command, which starts PHP's development server under the hood.The point here is that the environment doesn't use a proper web server (e.g. Nginx), which means we can't easily have local domain names, nor bring HTTPS to the setup. This may be fine for quick prototyping, but more elaborate development will most likely need those.The third issue is one I noticed while trying to clone and run a fresh instance of this article's for testing. While the process to create a new Laravel project based on Sail works well, I couldn't find proper instructions to instal and run an existing one.You can't run
./vendor/bin/sail up
because the vendor
folder doesn't exist yet. For this folder to be created, you need to run composer install
; but if your project relies on dependencies present on the Docker image but not on your local machine, composer install
won't work. You can run composer install --ignore-platform-reqs
instead, but that doesn't feel right. There should be a way to instal and run an existing project without relying on a local Composer instance and clunky commands.The last issue belongs to a separate category, as it relates to Docker overall and not Laravel Sail specifically. It should be carefully considered before going down the Docker road and deserves a section of its own.If you're considering building anything substantial using Laravel Sail as your development environment, sooner or later you will have to extend it. You'll find yourself fumbling around the Dockerfiles and eventually writing your own; having to add some services to
docker-compose.yml
; and maybe throwing in a few custom Bash commands.Once you get there, there's one question you should ask yourself:What's stopping me from building my own setup?
The answer is nothing. Once you feel comfortable extending Laravel Sail, you already have the knowledge required to build your own environment.
Think about it: the
docker-compose.yml
file is not specific to Laravel Sail, that's just how Docker Compose works. The same goes for Dockerfiles – they are standard Docker stuff. The Bash layer? That's all there is to it – some Bash code, and as you can see, it's not that complicated.So why artificially restrain yourself within the constraints of Sail?And more importantly: why limit yourself to using Docker in the context of Laravel?Your application may start as a monolith, but it might not always be. Perhaps you've got a separate frontend, and you use Laravel as the API layer. In that case, you might want your development environment to manage them both; to run them simultaneously so they interact with each other like they do on a staging environment or in production.If your whole application is a , your Docker configuration and Bash script could be at the root of the project, and you could have your frontend and backend applications in separate subfolders, e.g. under an
src
folder.The corresponding tree view would look something like this:my-app/
├── bash-script
├── docker-compose.yml
└── src/
├── backend/
│ └── Dockerfile
└── frontend/
└── Dockerfile
The
docker-compose.yml
file would declare two services – one for the backend and one for the frontend – both pointing to each's respective Dockerfile.If the backend and the frontend live in different repositories, you could create a third one, containing your Docker development environment exclusively. Just git-ignore the
src
folder and complete your Bash script so that it pulls both application repositories into it, using the same commands you would normally run by hand.Even if your project is a Laravel monolith, this kind of structure is already cleaner than mixing up development-related files with the rest of the source code. Moreover, if your application grows bigger and needs other components besides Laravel, you're already in a good position to support them.Once you've made the effort to understand Laravel Sail to extend it, nothing is stopping you from building your own development environments, whether or not Laravel is part of the equation. That's right, you can build bespoke Docker-based environments for anything.
And if Laravel is part of the stack, nothing prevents you from reusing Sail's Dockerfiles if you're not comfortable writing your own yet; after all, they are already optimised for Laravel. Likewise, you can draw inspiration from Sail's
docker-compose.yml
file if that helps.Truman hesitates. Perhaps he cannot go through with it after all. The camera slowly zooms into Truman's face.
_TRUMAN:_ "In case I don't see you – good afternoon, good evening and good night."
He steps through the door and is gone.
This story was originally published on .