Running specific PHP versions for Laravel can be quite useful, especially when working with legacy applications. I work on a range of different solutions with different versions of PHP and Laravel versions. To save me time reconfiguring my local environment’s PHP version and to better represent the live systems, I have opted for Docker based development environments. Here’s what I am aiming for:
- Customisable PHP versions
- Including libraries like Imagick and XDebug to make dev easier
- Self contained database instance
- Supporting queue worker, so I can test queues work locally
- Email catching, so I can test email notifications locally
- Redis, for queue management
- The Laravel Scheduler working
In order to achieve this, I’ve opted to use a docker-compose environment with custom docker PHP file. This defines the PHP version as well as any extra libraries in it that I need for the project. Then the project files (source code of the Laravel application) can be mounted as a volume. By mounting the project’s source code, it’s available for an editor on the host machine, while also being available for the PHP code to execute.
Let’s start by defining the project structure:
. ├── .docker │ ├── Dockerfile.app │ └── nginx │ └── default.conf ├── docker-compose.yml └── src
This structure tends to keep the Docker configuration and extra files neater, since they’re self-contained in a `.docker` directory. The custom PHP docker file (Dockerfile.app) is contained here, as is a subdirectory for Nginx, the webserver I’ll be using. Only the docker-compose file needs to be in the parent folder.
Lets start with the docker file. You’ll need to find your host user and group ID. On Linux (and presumably Mac) you can find this by running id -u
and id -g
. Normally they’re both 1000
. Replace the ARG entries in the docker file if your IDs are different.
If you’ve not created the directory structure already, do it now:
mkdir -p .docker/nginx
Now create the Docker file, I’m using Nano but you can use whatever editor you want: nano .docker/Dockerfile.app
FROM php:7.2-fpm # Define the User and Group ID for this docker file. This should match your host system UID and GID. ARG UID=1000 ARG GID=1000 # Set working directory for future docker commands WORKDIR /var/www/html # Install dependencies RUN apt-get update && apt-get install -y --quiet ca-certificates \ build-essential \ mariadb-client \ libpng-dev \ libxml2-dev \ libxrender1 \ wkhtmltopdf \ libjpeg62-turbo-dev \ libfreetype6-dev \ locales \ zip \ jpegoptim optipng pngquant gifsicle \ vim \ unzip \ curl \ libmcrypt-dev \ msmtp \ iproute2 \ libmagickwand-dev # Clear cache: keep the container slim RUN apt-get clean && rm -rf /var/lib/apt/lists/* # Xdebug # Note that "host.docker.internal" is not currently supported on Linux. This nasty hack tries to resolve it # Source: https://github.com/docker/for-linux/issues/264 RUN ip -4 route list match 0/0 | awk '{print $3" host.docker.internal"}' >> /etc/hosts # Install extensions: Some extentions are better installed using this method than apt in docker RUN docker-php-ext-configure gd --with-gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ --with-png-dir=/usr/include/ \ && docker-php-ext-install \ pdo_mysql \ mbstring \ zip \ exif \ pcntl \ xml \ soap \ bcmath \ gd # Install Redis, Imagick xDebug (Optional, but reccomended) and clear temp files RUN pecl install -o -f redis \ imagick \ xdebug \ && rm -rf /tmp/pear \ && docker-php-ext-enable redis \ imagick \ xdebug # Install composer: This could be removed and run in it's own container RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer # xdebug.remote_connect_back = true does NOT work in docker RUN echo '\n\ [Xdebug]\n\ xdebug.remote_enable=true\n\ xdebug.remote_autostart=true\n\ xdebug.remote_port=9000\n\ xdebug.remote_host=docker.host.internal\n'\ >> /usr/local/etc/php/php.ini RUN echo "request_terminate_timeout = 3600" >> /usr/local/etc/php-fpm.conf RUN echo "max_execution_time = 300" >> /usr/local/etc/php/php.ini # Xdebug # Note that "host.docker.internal" is not currently supported on Linux. This nasty hack tries to resolve it # Source: https://github.com/docker/for-linux/issues/264 #RUN ip -4 route list match 0/0 | awk '{print $3" host.docker.internal"}' >> /etc/hosts RUN ip -4 route list match 0/0 | awk '{print "xdebug.remote_host="$3}' >> /usr/local/etc/php/php.ini # Add user for laravel application RUN groupadd -g $GID www RUN useradd -u $UID -ms /bin/bash -g www www # Make sure permissions match host and container RUN chown www:www -R /var/www/html # Change current user to www USER www # Copy in a custom PHP.ini file # INCOMPLETE/UNTESTED #COPY source /usr/local/etc/php/php.ini # We should do this as a command once the container is up. # Leaving here incase someone wants to enable it here... #RUN composer install && composer dump-autoload -o
I’ve left in some commented commands, which can be uncommented and customised if needed. The file comments should also help you make any changes as needed, but the file should work for you as is.
Next, lets create the nginx configuration file nano .docker/nginx/default.conf
server { listen 80 default_server; root /var/www/html/public; index.php index index.html index.htm; charset utf-8; location = /favicon.ico { log_not_found off; access_log off; } location = /robots.txt { log_not_found off; access_log off; } location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ ^/.+\.php(/|$) { fastcgi_pass php:9000; fastcgi_split_path_info ^(.+\.php)(/.*)$; fastcgi_read_timeout 3600; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS off; } error_page 404 /index.php; location ~ /\.ht { deny all; } }
The most important part of this file is the fastcgi_pass php:9000;
line. This tells nginx in it’s container where to find PHP running in it’s container. You’ll see that tie in the docker compose file.
Create the docker-compose.yml file nano docker-compose.yml
version: '3' services: # Nginx web server nginx: image: nginx:stable-alpine ports: # OPTIONAL: change the port number before the colon ":" to alter we traffic port - "8080:80" volumes: - ./src:/var/www/html - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf depends_on: # for this container to run, wait until PHP and MYSQL are running - php - mysql networks: # OPTIONAL: change or remove the network name (do this for all containers) - laravel # MySQL database server mysql: image: mysql:5.7 restart: unless-stopped tty: true ports: # OPTIONAL: Change the port number before the colon ":" to alter where MySQL binds on the host # Allow connections to MySQL from the host (MySQL Workbench, DataGrip, etc) on port 33060 # WARNING: do not expose in production! - "3306:3306" environment: # OPTIONAL: Change MySQL credentials MYSQL_ROOT_PASSWORD: secret MYSQL_DATABASE: laravel MYSQL_USER: laravel MYSQL_PASSWORD: secret SERVICE_TAGS: dev SERVICE_NAME: mysql networks: - laravel volumes: # Persist MySQL data with a docker volume (see end of file) - mysql_data:/var/lib/mysql # Custom PHP image for Laravel php: build: context: . dockerfile: ./.docker/Dockerfile.app volumes: - ./src:/var/www/html # Load a custom PHP.ini file #- ./.docker/php/php.ini:/usr/local/etc/php/php.ini #command: ip -4 route list match 0/0 | awk '{print $$3" host.docker.internal"}' >> /etc/hosts networks: - laravel # Redis, for caching and queues (Optional) redis: image: redis:5-alpine restart: unless-stopped # OPTIONAL: change or open up Redis port binding. # Disabled by default for security. Redis should not be exposed to the world! # your other containers should still be able to access it without this enabled #ports: #- 6379:6379 networks: - laravel # Laravel Horizion (Optional) # NOTE: if you're not running horizon, you should delete this stanza or you'll get errors horizon: build: context: . dockerfile: ./.docker/Dockerfile.app restart: unless-stopped command: /bin/bash -c 'while [ 0 -lt 1 ] ; do php artisan horizon; sleep 60; done' networks: - laravel volumes: - ./src:/var/www/html # Laravel Scheduler (Optional) scheduler: build: context: . dockerfile: ./.docker/Dockerfile.app restart: unless-stopped command: /bin/bash -c 'while [ 0 -lt 1 ] ; do php artisan schedule:run >> /dev/null 2>&1 ; sleep 60; done' networks: - laravel volumes: - ./src:/var/www/html # Default Queue Worker (Optional) worker-default: build: context: . dockerfile: ./.docker/Dockerfile.app restart: unless-stopped command: /bin/bash -c 'while [ 0 -lt 1 ] ; do php artisan queue:work --tries=3 --timeout=90 --sleep=10; done' networks: - laravel volumes: - ./src:/var/www/html # Mailhug (Optional, mail-catcher) # Comment out or delete this if you don't want to use it mailhog: image: mailhog/mailhog networks: - laravel ports: # Uncomment to allow host access to SMTP (not sure why you'd want to?!) # your containers on the same network can still access this without the binding # - 1025:1025 # smtp server # OPTIONAL: Change the port number before the colon ":" to alter where the Mailhog UI can be accessed - 8025:8025 # web ui networks: # A network for the laravel containers laravel: # Persist the MySQL data volumes: mysql_data:
This is quite a big file. Each container is defined inside the service block. Most are provided containers from dockerhub. There’s a few important things to know (which are mostly commented in the file).
The Nginx container has ports exposed. I’ve set these to 8080 externally, mapping to port 80 internally. So to access the site in your browser navigate to http://localhost:8080
. The next thing the container does is mount two volumes. The first is the source code for your application, the second is the default.conf nginx file written above.
The MySQL container has port 3306
count to the host, allowing access from a MySQL management tool such as MySQL Workbench, DataGrip or DBeaver. You absolutely should not run this on a production server without firewalling it. Infact this whole environment is designed for local development, but this particularly needs raised as a point for anyone adapting this for production. Do not expose MySQL to the world! Other settings of interest here are the MYSQL_
segments. You can use these to define your username, password, database name. Additionally, the configuration mounts a volume to the MySQL database directory which means the data will be persistent until the volume is deleted. You can optionally remove this if you want volatile data that’s deleted on container restart.
The PHP container’s name is important. This relates to the nginx configuration file, where the fast_cgi
parameters was defined. If you change the container definition form php:
to something else, you’ll need to update it in the nginx default.conf
as well as elsewhere in this file. The PHP image also needs to have a volume for the source code, and this needs to be the same path as the nginx container. Because this is a custom docker file, this needs built by docker-compose instead of just pulling an image. You can of course create this image and upload it to somewhere like dockerhub and include it from there, but I like to keep the environment customisable without messing around with external docker hubs.
The other containers are entirely optional. If you’re not running Horizon, then just remove or comment out that block. Same with the other remaining containers.
Next thing to do is create a new Laravel install in the src
directory, or copy in an existing Laravel repo. Generally I install a new Laravel instance using composer like this:
`
composer create-project --prefer-dist laravel/laravel src
Now all that’s left to do is run docker-compose up -d
. It’ll build the PHP image, pull the MySQL and nginx image and start your containers using the ports specified in the docker-compose file. To run composer or artisan commands, simply run docker-compose exec php bash
and you’ll be dropped into the web directory on the PHP docker container. From here you can easily run commands such as php artisan key:generate
, php artisan migrate
and any of the php artisan make:
commands.
It’s also possible to version control your src
folder. Do this from the host, and not inside a docker container. cd src
to go into the source code directory, as it’d be unusual for you to store your dev environment with the application. git init
should initialise a new git repository for you to manage as you see fit.