├── .editorconfig
├── .examples
├── docker-compose
│ ├── docker-compose.yml
│ └── nginx
│ │ └── app.conf
└── kubernetes
│ ├── kustomization.yaml
│ ├── nginx.config-map.yaml
│ ├── nginx.deployment.yaml
│ ├── nginx.svc.yaml
│ ├── php.config-map.yaml
│ ├── php.deployment.yaml
│ ├── php.svc.yaml
│ ├── postgres.deployment.yaml
│ ├── postgres.pvc.yaml
│ ├── postgres.svc.yaml
│ ├── redis.deployment.yaml
│ └── redis.svc.yaml
├── .github
├── dependabot.yml
└── workflows
│ ├── pint.yml
│ └── tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── bin
├── common-functions
├── docker-build
└── docker-push
├── composer.json
├── config
└── docker-builder.php
├── phpunit.xml.dist
├── resources
└── templates
│ ├── .dockerignore
│ ├── ci-platforms
│ ├── .gitlab-ci.yml
│ └── github-workflow.yml
│ ├── nginx.dockerfile.twig
│ ├── partials
│ ├── node-build-assets.dockerfile.twig
│ └── node-copy-assets.dockerfile.twig
│ └── php.dockerfile.twig
├── src
├── Commands
│ ├── BaseCommand.php
│ ├── BaseDockerCommand.php
│ ├── DockerBuildCommand.php
│ ├── DockerCiCommand.php
│ ├── DockerGenerateCommand.php
│ ├── DockerPushCommand.php
│ └── GenerateQuestions
│ │ ├── AlpineQuestion.php
│ │ ├── ArtisanOptimizeQuestion.php
│ │ ├── BaseQuestion.php
│ │ ├── Choices
│ │ ├── CiPlatform.php
│ │ ├── NodeBuildTool.php
│ │ ├── NodePackageManager.php
│ │ ├── PhpExtensions.php
│ │ └── PhpVersion.php
│ │ ├── NodeBuildToolQuestion.php
│ │ ├── NodePackageManagerQuestion.php
│ │ ├── PhpExtensionsQuestion.php
│ │ └── PhpVersionQuestion.php
├── Detectors
│ ├── CiPlatformDetector.php
│ ├── DetectorContract.php
│ ├── FileDetector.php
│ ├── NodeBuildToolDetector.php
│ ├── NodePackageManagerDetector.php
│ ├── PhpExtensionsDetector.php
│ └── PhpVersionDetector.php
├── DockerServiceProvider.php
├── Exceptions
│ └── InvalidOptionValueException.php
├── Integrations
│ └── SupportedPhpExtensions.php
├── Objects
│ └── Configuration.php
├── Traits
│ └── InteractsWithTwig.php
└── helpers.php
└── tests
├── Commands
├── BaseCommandTest.php
├── BaseDockerCommandTest.php
├── DockerBuildCommandTest.php
├── DockerCiCommandTest.php
├── DockerGenerateCommandTest.php
├── DockerPushCommandTest.php
└── GenerateQuestions
│ ├── AlpineQuestionTest.php
│ ├── ArtisanOptimizeQuestionTest.php
│ ├── Choices
│ ├── CiPlatformTest.php
│ ├── NodeBuildToolTest.php
│ ├── NodePackageManagerTest.php
│ ├── PhpExtensionsTest.php
│ └── PhpVersionTest.php
│ ├── NodeBuildToolQuestionTest.php
│ ├── NodePackageManagerQuestionTest.php
│ ├── PhpExtensionsQuestionTest.php
│ └── PhpVersionQuestionTest.php
├── Detectors
├── CiPlatformDetectorTest.php
├── FileDetectorTest.php
├── NodeBuildToolDetectorTest.php
├── NodePackageManagerTest.php
├── PhpExtensionsDetectorTest.php
└── PhpVersionDetectorTest.php
├── DockerServiceProviderTest.php
├── HelpersTest.php
├── Integrations
└── SupportedPhpExtensionsTest.php
├── Objects
└── ConfigurationTest.php
├── Pest.php
├── TestCase.php
└── Traits
└── InteractsWithTwigTest.php
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_size = 4
7 | indent_style = space
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
14 | [*.{yml,yaml}]
15 | indent_size = 2
--------------------------------------------------------------------------------
/.examples/docker-compose/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | nginx:
3 | image: "laravel-docker:nginx"
4 | depends_on:
5 | - php
6 | volumes:
7 | - "./nginx/app.conf:/etc/nginx/conf.d/default.conf"
8 | ports:
9 | - "80:80"
10 | php:
11 | image: "laravel-docker:php"
12 | depends_on:
13 | - postgres
14 | - redis
15 | - minio
16 | environment:
17 | APP_NAME: "Laravel Docker"
18 | APP_ENV: "production"
19 | APP_KEY: "base64:Avm4zs1yLfogxHpwBZRZhKZJ0EC6/7IX0FcVSyQQlLU="
20 | APP_DEBUG: "false"
21 | APP_URL: "http://localhost"
22 | LOG_CHANNEL: "stderr"
23 | # Postgres configuration
24 | DB_CONNECTION: "pgsql"
25 | DB_HOST: "postgres"
26 | DB_DATABASE: "laravel-app"
27 | DB_USERNAME: "laravel"
28 | DB_PASSWORD: "password"
29 | # Redis configuration
30 | REDIS_HOST: "redis"
31 | REDIS_PORT: "6379"
32 | BROADCAST_DRIVER: "redis"
33 | CACHE_DRIVER: "redis"
34 | QUEUE_CONNECTION: "redis"
35 | SESSION_DRIVER: "redis"
36 | # Minio configuration
37 | FILESYSTEM_DISK: "s3"
38 | AWS_ACCESS_KEY_ID: "laravel"
39 | AWS_SECRET_ACCESS_KEY: "password"
40 | AWS_DEFAULT_REGION: "us-east-1"
41 | AWS_BUCKET: "local"
42 | AWS_URL: "http://localhost:9000"
43 | AWS_ENDPOINT: "http://minio:9000"
44 | AWS_USE_PATH_STYLE_ENDPOINT: "true"
45 | postgres:
46 | image: "postgres:15"
47 | environment:
48 | PGPASSWORD: "password"
49 | POSTGRES_DB: "laravel-app"
50 | POSTGRES_USER: "laravel"
51 | POSTGRES_PASSWORD: "password"
52 | volumes:
53 | - "postgres-data:/var/lib/postgresql/data"
54 | healthcheck:
55 | test: [ "CMD", "pg_isready", "-q", "-d", "laravel-app", "-U", "laravel" ]
56 | retries: 3
57 | timeout: 5s
58 | redis:
59 | image: "redis:alpine"
60 | volumes:
61 | - "redis-data:/data"
62 | healthcheck:
63 | test: [ "CMD", "redis-cli", "ping" ]
64 | retries: 3
65 | timeout: 5s
66 | minio:
67 | image: "minio/minio:latest"
68 | environment:
69 | MINIO_ROOT_USER: "laravel"
70 | MINIO_ROOT_PASSWORD: "password"
71 | ports:
72 | - "9000:9000"
73 | - "8900:8900"
74 | volumes:
75 | - "minio-data:/data/minio"
76 | entrypoint: sh
77 | command: -c "mkdir -p /data/minio/local && minio server /data/minio --console-address ':8900'"
78 | healthcheck:
79 | test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
80 | retries: 3
81 | timeout: 5s
82 | volumes:
83 | postgres-data: { }
84 | redis-data: { }
85 | minio-data: { }
--------------------------------------------------------------------------------
/.examples/docker-compose/nginx/app.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | listen [::]:80;
4 | root /app/public;
5 |
6 | add_header X-Frame-Options "SAMEORIGIN";
7 | add_header X-Content-Type-Options "nosniff";
8 |
9 | index index.php;
10 |
11 | charset utf-8;
12 |
13 | location / {
14 | try_files $uri $uri/ /index.php?$query_string;
15 | }
16 |
17 | location = /favicon.ico { access_log off; log_not_found off; }
18 | location = /robots.txt { access_log off; log_not_found off; }
19 |
20 | error_page 404 /index.php;
21 |
22 | location ~ \.php$ {
23 | fastcgi_pass php:9000;
24 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
25 | include fastcgi_params;
26 | }
27 |
28 | location ~ /\.(?!well-known).* {
29 | deny all;
30 | }
31 | }
--------------------------------------------------------------------------------
/.examples/kubernetes/kustomization.yaml:
--------------------------------------------------------------------------------
1 | resources:
2 | - postgres.pvc.yaml
3 | - postgres.deployment.yaml
4 | - postgres.svc.yaml
5 | - redis.deployment.yaml
6 | - redis.svc.yaml
7 | - php.config-map.yaml
8 | - php.deployment.yaml
9 | - php.svc.yaml
10 | - nginx.config-map.yaml
11 | - nginx.deployment.yaml
12 | - nginx.svc.yaml
--------------------------------------------------------------------------------
/.examples/kubernetes/nginx.config-map.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: ConfigMap
3 | metadata:
4 | name: nginx-config
5 | data:
6 | default.conf.template: |
7 | server {
8 | listen 80;
9 | listen [::]:80;
10 | root /app/public;
11 |
12 | add_header X-Frame-Options "SAMEORIGIN";
13 | add_header X-Content-Type-Options "nosniff";
14 |
15 | index index.php;
16 |
17 | charset utf-8;
18 |
19 | location / {
20 | try_files $uri $uri/ /index.php?$query_string;
21 | }
22 |
23 | location = /favicon.ico { access_log off; log_not_found off; }
24 | location = /robots.txt { access_log off; log_not_found off; }
25 |
26 | error_page 404 /index.php;
27 |
28 | location ~ \.php$ {
29 | fastcgi_pass ${PHP_SERVICE_HOST}:${PHP_SERVICE_PORT};
30 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
31 | include fastcgi_params;
32 | }
33 |
34 | location ~ /\.(?!well-known).* {
35 | deny all;
36 | }
37 | }
--------------------------------------------------------------------------------
/.examples/kubernetes/nginx.deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: nginx
5 | labels:
6 | app: laravel-docker
7 | tier: nginx
8 | spec:
9 | replicas: 1
10 | template:
11 | metadata:
12 | name: nginx
13 | labels:
14 | app: laravel-docker
15 | tier: nginx
16 | spec:
17 | containers:
18 | - name: nginx
19 | image: laravel-docker:nginx
20 | imagePullPolicy: Never # local development only
21 | ports:
22 | - containerPort: 80
23 | name: nginx-http
24 | volumeMounts:
25 | - mountPath: /etc/nginx/templates/default.conf.template
26 | name: nginx-config-volume
27 | subPath: default.conf.template
28 | restartPolicy: Always
29 | volumes:
30 | - name: nginx-config-volume
31 | configMap:
32 | name: nginx-config
33 | selector:
34 | matchLabels:
35 | app: laravel-docker
36 | tier: nginx
37 |
--------------------------------------------------------------------------------
/.examples/kubernetes/nginx.svc.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: nginx
5 | spec:
6 | selector:
7 | app: laravel-docker
8 | tier: nginx
9 | ports:
10 | - port: 80
11 | type: NodePort
--------------------------------------------------------------------------------
/.examples/kubernetes/php.config-map.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: ConfigMap
3 | metadata:
4 | name: laravel-config
5 | data:
6 | APP_NAME: "Laravel Docker"
7 | APP_ENV: "production"
8 | APP_KEY: "base64:Avm4zs1yLfogxHpwBZRZhKZJ0EC6/7IX0FcVSyQQlLU="
9 | APP_DEBUG: "false"
10 | APP_URL: "http://localhost"
11 | LOG_CHANNEL: "stderr"
12 | # Postgres configuration
13 | DB_CONNECTION: "pgsql"
14 | DB_HOST: "postgres"
15 | DB_PORT: "5432"
16 | DB_DATABASE: "laravel-app"
17 | DB_USERNAME: "laravel"
18 | DB_PASSWORD: "password"
19 | # Redis configuration
20 | REDIS_HOST: "redis"
21 | REDIS_PORT: "6379"
22 | BROADCAST_DRIVER: "redis"
23 | CACHE_DRIVER: "redis"
24 | QUEUE_CONNECTION: "redis"
25 | SESSION_DRIVER: "redis"
26 | # Minio configuration
27 | FILESYSTEM_DISK: "s3"
28 | AWS_ACCESS_KEY_ID: "laravel"
29 | AWS_SECRET_ACCESS_KEY: "password"
30 | AWS_DEFAULT_REGION: "us-east-1"
31 | AWS_BUCKET: "local"
32 | AWS_URL: "http://localhost:9000"
33 | AWS_ENDPOINT: "http://minio:9000"
34 | AWS_USE_PATH_STYLE_ENDPOINT: "true"
35 | ---
36 |
--------------------------------------------------------------------------------
/.examples/kubernetes/php.deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: php
5 | labels:
6 | app: laravel-docker
7 | tier: php
8 | spec:
9 | replicas: 3
10 | template:
11 | metadata:
12 | name: php
13 | labels:
14 | app: laravel-docker
15 | tier: php
16 | spec:
17 | containers:
18 | - name: php
19 | image: laravel-docker:php
20 | imagePullPolicy: Never
21 | envFrom:
22 | - configMapRef:
23 | name: laravel-config
24 | ports:
25 | - containerPort: 9000
26 | name: php-fpm
27 | initContainers:
28 | - name: migrations
29 | image: laravel-docker:php
30 | envFrom:
31 | - configMapRef:
32 | name: laravel-config
33 | command:
34 | - /bin/bash
35 | - -c
36 | - php artisan migrate --force
37 | restartPolicy: Always
38 | selector:
39 | matchLabels:
40 | app: laravel-docker
41 | tier: php
42 |
--------------------------------------------------------------------------------
/.examples/kubernetes/php.svc.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: php
5 | spec:
6 | selector:
7 | app: laravel-docker
8 | tier: php
9 | ports:
10 | - port: 9000
11 | type: NodePort
--------------------------------------------------------------------------------
/.examples/kubernetes/postgres.deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: postgres
5 | labels:
6 | app: laravel-docker
7 | tier: postgres
8 | spec:
9 | replicas: 1
10 | template:
11 | metadata:
12 | name: postgres
13 | labels:
14 | app: laravel-docker
15 | tier: postgres
16 | spec:
17 | containers:
18 | - name: postgres
19 | image: postgres:15
20 | imagePullPolicy: IfNotPresent
21 | env:
22 | - name: POSTGRES_DB
23 | valueFrom:
24 | configMapKeyRef:
25 | key: DB_DATABASE
26 | name: laravel-config
27 | - name: POSTGRES_USER
28 | valueFrom:
29 | configMapKeyRef:
30 | key: DB_USERNAME
31 | name: laravel-config
32 | - name: POSTGRES_PASSWORD
33 | valueFrom:
34 | configMapKeyRef:
35 | key: DB_PASSWORD
36 | name: laravel-config
37 | ports:
38 | - containerPort: 5432
39 | name: postgres
40 | volumeMounts:
41 | - mountPath: /var/lib/postgresql/data
42 | name: postgres-persistent-storage
43 | restartPolicy: Always
44 | volumes:
45 | - name: postgres-persistent-storage
46 | persistentVolumeClaim:
47 | claimName: postgres-pv-claim
48 | selector:
49 | matchLabels:
50 | app: laravel-docker
51 | tier: postgres
52 |
--------------------------------------------------------------------------------
/.examples/kubernetes/postgres.pvc.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: PersistentVolumeClaim
3 | metadata:
4 | name: postgres-pv-claim
5 | labels:
6 | app: laravel-docker
7 | spec:
8 | accessModes:
9 | - ReadWriteOnce
10 | resources:
11 | requests:
12 | storage: 1Gi
--------------------------------------------------------------------------------
/.examples/kubernetes/postgres.svc.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: postgres
5 | spec:
6 | selector:
7 | app: laravel-docker
8 | tier: postgres
9 | ports:
10 | - port: 5432
11 | type: NodePort
--------------------------------------------------------------------------------
/.examples/kubernetes/redis.deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: redis
5 | labels:
6 | app: laravel-docker
7 | tier: redis
8 | spec:
9 | replicas: 1
10 | template:
11 | metadata:
12 | name: redis
13 | labels:
14 | app: laravel-docker
15 | tier: redis
16 | spec:
17 | containers:
18 | - name: redis
19 | image: redis:alpine
20 | imagePullPolicy: IfNotPresent
21 | ports:
22 | - containerPort: 6379
23 | name: redis
24 | restartPolicy: Always
25 | selector:
26 | matchLabels:
27 | app: laravel-docker
28 | tier: redis
29 |
--------------------------------------------------------------------------------
/.examples/kubernetes/redis.svc.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: redis
5 | spec:
6 | selector:
7 | app: laravel-docker
8 | tier: redis
9 | ports:
10 | - port: 6379
11 | type: NodePort
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Please see the documentation for all configuration options:
2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3 |
4 | version: 2
5 | updates:
6 |
7 | - package-ecosystem: "github-actions"
8 | directory: "/"
9 | schedule:
10 | interval: "weekly"
11 | labels:
12 | - "dependencies"
13 |
14 | - package-ecosystem: "composer"
15 | directory: "/"
16 | schedule:
17 | interval: "weekly"
18 | labels:
19 | - "dependencies"
--------------------------------------------------------------------------------
/.github/workflows/pint.yml:
--------------------------------------------------------------------------------
1 | name: Laravel Pint
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - name: Validate composer.json and composer.lock
21 | run: composer validate --strict
22 |
23 | - name: Cache Composer packages
24 | id: composer-cache
25 | uses: actions/cache@v4
26 | with:
27 | path: vendor
28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
29 | restore-keys: |
30 | ${{ runner.os }}-php-
31 |
32 | - name: Install dependencies
33 | run: composer install --prefer-dist --no-progress
34 |
35 | - name: Run code style check
36 | run: vendor/bin/pint --test
37 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: PHPUnit
2 |
3 | on:
4 | push:
5 | paths:
6 | - '**.php'
7 | - '.github/workflows/tests.yml'
8 | - 'phpunit.xml.dist'
9 | - 'composer.json'
10 | - 'composer.lock'
11 |
12 | jobs:
13 | phpunit:
14 | timeout-minutes: 5
15 | strategy:
16 | fail-fast: true
17 | matrix:
18 | os: [ ubuntu-latest ]
19 | php: [ 8.3, 8.2, 8.1 ]
20 | laravel: [ 11.*, 10.* ]
21 | stability: [ prefer-lowest, prefer-stable ]
22 | include:
23 | - laravel: '10.*'
24 | testbench: '8.*'
25 | - laravel: '11.*'
26 | testbench: '9.*'
27 | exclude:
28 | - laravel: '11.*'
29 | php: '8.1'
30 |
31 | runs-on: ${{ matrix.os }}
32 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
33 |
34 | steps:
35 | - name: Checkout code
36 | uses: actions/checkout@v4
37 |
38 | - name: Setup PHP
39 | uses: shivammathur/setup-php@v2
40 | with:
41 | php-version: ${{ matrix.php }}
42 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
43 | coverage: none
44 |
45 | - name: Install dependencies
46 | run: |
47 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
48 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction
49 |
50 | - name: List Installed Dependencies
51 | run: composer show -D
52 |
53 | - name: Run test suite
54 | run: vendor/bin/pest --ci
55 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .phpunit.cache
3 | build
4 | composer.lock
5 | coverage
6 | docs
7 | phpunit.xml
8 | phpstan.neon
9 | testbench.yaml
10 | vendor
11 | node_modules
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 BlameButton
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | [](https://packagist.org/packages/blamebutton/laravel-docker-builder)
4 | [](https://packagist.org/packages/blamebutton/laravel-docker-builder)
5 | 
6 |
7 | ## Features
8 |
9 | * Build Docker images using the Artisan CLI
10 | * Detect PHP version and extensions
11 | * Bundle assets with Vite.js or Laravel Mix
12 | * Separate NGINX and PHP-FPM images
13 | * [Deployment examples](/.examples/) for Kubernetes and Docker Compose
14 |
15 | ## Installation
16 |
17 | ```shell
18 | composer require --dev blamebutton/laravel-docker-builder
19 | ```
20 |
21 | ## Usage
22 |
23 | ### Detect Configuration
24 |
25 | ```shell
26 | php artisan docker:generate --detect
27 | ```
28 |
29 | When `--detect` is passed to the `docker:generate` command, it will automatically detect the following requirements:
30 |
31 | * PHP version, detected using the `php` version in your `composer.json`
32 | * PHP extensions, detected using the configuration of your project:
33 | * Cache driver: Redis, Memcached, APC
34 | * Database driver: MySQL, Postgres, SQL Server
35 | * Broadcasting driver: Redis
36 | * Queue driver: Redis
37 | * Session driver: Redis, Memcached, APC
38 | * Node package manager, detected using the existence of `package-lock.json` or `yarn.lock`
39 | * Node build tool, detected using the existence of `vite.config.js` or `webpack.mix.js`
40 |
41 | ### Manual Configuration
42 |
43 | ```shell
44 | php artisan docker:generate
45 | ```
46 |
47 | When no options are passed to `docker:generate`, a prompt is used to configure the project's requirements.
48 |
49 | See all available options, and their supported values, by running `php artisan docker:generate --help`.
50 |
51 | * `-p, --php-version` - PHP version for Docker image
52 | * `-e, --php-extensions` - PHP extensions (comma-separated) to include in Docker image
53 | * `-o, --optimize` - Run `php artisan optimize` on container start
54 | * `-a, --alpine` - Use Alpine Linux based images
55 | * `-m, --node-package-manager` - Install Node dependencies using NPM or Yarn
56 | * `-b, --node-build-tool` - Run Vite.js or Laravel Mix build step
57 |
58 | ## Configuration
59 |
60 | ### Option 1: Config File
61 |
62 | ```shell
63 | php artisan vendor:publish --provider="BlameButton\LaravelDockerBuilder\DockerServiceProvider"
64 | ```
65 |
66 | ### Option 2: `.env`
67 |
68 | By default, the configuration file reads the following environment variables to determine the Docker image tags.
69 |
70 | ```shell
71 | DOCKER_NGINX_TAG=laravel-app:nginx
72 | DOCKER_PHP_TAG=laravel-app:php
73 | ```
74 |
--------------------------------------------------------------------------------
/bin/common-functions:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | check_laravel() {
4 | if ! [[ -f "${PWD}/public/index.php" ]]; then
5 | echo "Missing [/public/index.php], please run from Laravel base directory.";
6 | exit 1
7 | fi
8 | }
9 |
10 | check_dockerfiles() {
11 | if ! [[ -f "$PWD/.docker/nginx.dockerfile" ]]; then
12 | echo "Dockerfile [/.docker/nginx.dockerfile] not found."
13 | echo "Run: php artisan docker:generate"
14 | exit 1
15 | fi
16 |
17 | if ! [[ -f "$PWD/.docker/php.dockerfile" ]]; then
18 | echo "Dockerfile [/.docker/php.dockerfile] not found."
19 | echo "Run: php artisan docker:generate"
20 | exit 1
21 | fi
22 | }
23 |
24 | check_tags() {
25 | NGINX_TAG="${DOCKER_NGINX_TAG}"
26 | PHP_TAG="${DOCKER_PHP_TAG}"
27 |
28 | if [[ -z "${NGINX_TAG}" ]]; then
29 | echo "Environment variable [DOCKER_NGINX_TAG] not found."
30 | exit 1
31 | fi
32 |
33 | if [[ -z "${PHP_TAG}" ]]; then
34 | echo "Environment variable [DOCKER_PHP_TAG] not found."
35 | exit 1
36 | fi
37 | }
38 |
--------------------------------------------------------------------------------
/bin/docker-build:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
4 | PACKAGE="${SCRIPT_DIR}/.."
5 |
6 | source "${SCRIPT_DIR}/common-functions"
7 |
8 | check_laravel
9 | check_dockerfiles
10 | check_tags
11 |
12 | NGINX_TAG="${DOCKER_NGINX_TAG}"
13 | PHP_TAG="${DOCKER_PHP_TAG}"
14 |
15 | if ! [[ -f "${PWD}/.dockerignore" ]]; then
16 | echo "Missing [/.dockerignore], copying to [${PWD}/.dockerignore]";
17 | cp "${PACKAGE}/resources/templates/.dockerignore" "${PWD}/.dockerignore"
18 | fi
19 |
20 | docker build \
21 | --tag "${NGINX_TAG}" \
22 | --file "${PWD}/.docker/nginx.dockerfile" \
23 | "${PWD}"
24 |
25 | docker build \
26 | --tag "${PHP_TAG}" \
27 | --file "${PWD}/.docker/php.dockerfile" \
28 | "${PWD}"
29 |
--------------------------------------------------------------------------------
/bin/docker-push:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
4 |
5 | source "${SCRIPT_DIR}/common-functions"
6 |
7 | check_laravel
8 | check_dockerfiles
9 | check_tags
10 |
11 | NGINX_TAG="${DOCKER_NGINX_TAG}"
12 | PHP_TAG="${DOCKER_PHP_TAG}"
13 |
14 | docker push "${NGINX_TAG}"
15 | docker push "${PHP_TAG}"
16 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blamebutton/laravel-docker-builder",
3 | "description": "Production ready Docker files for Laravel",
4 | "type": "library",
5 | "keywords": [
6 | "laravel",
7 | "docker",
8 | "image",
9 | "generate",
10 | "kubernetes"
11 | ],
12 | "homepage": "https://github.com/blamebutton/laravel-docker-builder",
13 | "license": "MIT",
14 | "authors": [
15 | {
16 | "name": "Bram Ceulemans",
17 | "email": "bram@ceulemans.dev",
18 | "homepage": "https://github.com/bram-pkg"
19 | }
20 | ],
21 | "support": {
22 | "issues": "https://github.com/blamebutton/laravel-docker-builder/issues",
23 | "source": "https://github.com/blamebutton/laravel-docker-builder"
24 | },
25 | "require": {
26 | "php": "^8.1",
27 | "composer/semver": "^3.3",
28 | "guzzlehttp/guzzle": "^7.5",
29 | "illuminate/contracts": "^10.0 | ^11.0",
30 | "pestphp/pest": "^2.30",
31 | "twig/twig": "^3.0"
32 | },
33 | "require-dev": {
34 | "laravel/pint": "^1.13.7",
35 | "nunomaduro/collision": "^7.10 | ^8.0",
36 | "orchestra/testbench": "^8.19 | ^9.0"
37 | },
38 | "autoload": {
39 | "psr-4": {
40 | "BlameButton\\LaravelDockerBuilder\\": "src/"
41 | },
42 | "files": [
43 | "src/helpers.php"
44 | ]
45 | },
46 | "autoload-dev": {
47 | "psr-4": {
48 | "BlameButton\\LaravelDockerBuilder\\Tests\\": "tests/"
49 | }
50 | },
51 | "minimum-stability": "dev",
52 | "prefer-stable": true,
53 | "config": {
54 | "optimize-autoloader": true,
55 | "preferred-install": "dist",
56 | "sort-packages": true,
57 | "allow-plugins": {
58 | "pestphp/pest-plugin": true
59 | }
60 | },
61 | "scripts": {
62 | "post-autoload-dump": "@composer run prepare",
63 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
64 | "prepare": "@php vendor/bin/testbench package:discover --ansi",
65 | "build": [
66 | "@composer run prepare",
67 | "@php vendor/bin/testbench workbench:build --ansi"
68 | ],
69 | "start": [
70 | "Composer\\Config::disableProcessTimeout",
71 | "@composer run build",
72 | "@php vendor/bin/testbench serve"
73 | ],
74 | "analyse": "vendor/bin/phpstan analyse",
75 | "test": "vendor/bin/pest",
76 | "test-coverage": "vendor/bin/pest --coverage",
77 | "format": "vendor/bin/pint"
78 | },
79 | "extra": {
80 | "laravel": {
81 | "providers": [
82 | "BlameButton\\LaravelDockerBuilder\\DockerServiceProvider"
83 | ]
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/config/docker-builder.php:
--------------------------------------------------------------------------------
1 | [
9 | 'nginx' => env('DOCKER_NGINX_TAG', "$tag:nginx"),
10 | 'php' => env('DOCKER_PHP_TAG', "$tag:php"),
11 | ],
12 | ];
13 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 | tests
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/resources/templates/.dockerignore:
--------------------------------------------------------------------------------
1 | # Laravel
2 | /bootstrap/cache/*
3 | /public/build/
4 | /public/storage/
5 | /storage/*
6 |
7 | # Development
8 | /tests/
9 | /phpunit.xml
10 |
11 | # Dependencies
12 | /node_modules/
13 | /vendor/
14 |
15 | # Docker
16 | /.docker/
17 | /docker-compose.yml
18 |
--------------------------------------------------------------------------------
/resources/templates/ci-platforms/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | .docker-build:
2 | # Use the official docker image.
3 | image: docker:latest
4 | stage: build
5 | services:
6 | - docker:dind
7 | before_script:
8 | - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
9 | # Default branch leaves tag empty (= latest tag)
10 | # All other branches are tagged with the escaped branch name (commit ref slug)
11 | script:
12 | - |
13 | if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
14 | TAG=""
15 | echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'"
16 | else
17 | TAG=":$CI_COMMIT_REF_SLUG"
18 | echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag"
19 | fi
20 | - docker pull "$CI_REGISTRY_IMAGE/${NAME}${TAG}" || true
21 | - docker build --pull -f .docker/${NAME}.dockerfile --cache-from "$CI_REGISTRY_IMAGE/${NAME}${TAG}" -t "$CI_REGISTRY_IMAGE/${NAME}${TAG}" .
22 | - docker push "$CI_REGISTRY_IMAGE/${NAME}${TAG}"
23 | # Run this job in a branch where a Dockerfile exists
24 | rules:
25 | - if: $CI_COMMIT_BRANCH
26 | exists:
27 | - .docker/$NAME.dockerfile
28 |
29 | docker-build-nginx:
30 | extends: .docker-build
31 | variables:
32 | NAME: nginx
33 |
34 | docker-build-php:
35 | extends: .docker-build
36 | variables:
37 | NAME: php
38 |
39 | docker-build-ssr:
40 | extends: .docker-build
41 | variables:
42 | NAME: ssr
43 |
--------------------------------------------------------------------------------
/resources/templates/ci-platforms/github-workflow.yml:
--------------------------------------------------------------------------------
1 | name: "CI"
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | docker:
13 | name: Docker Build & Deploy
14 | runs-on: ubuntu-latest
--------------------------------------------------------------------------------
/resources/templates/nginx.dockerfile.twig:
--------------------------------------------------------------------------------
1 | {# import node partial, exposed as "node" layer #}
2 | {% include 'partials/node-build-assets.dockerfile.twig' %}
3 |
4 | FROM nginx:1{{ alpine ? '-alpine' : '' }}
5 |
6 | WORKDIR /app/public
7 |
8 | {# import node-copy-assets partial, copy assets from node layer #}
9 | {% include 'partials/node-copy-assets.dockerfile.twig' %}
10 |
11 | COPY /public/ /app/public/
12 |
--------------------------------------------------------------------------------
/resources/templates/partials/node-build-assets.dockerfile.twig:
--------------------------------------------------------------------------------
1 | {% if node_package_manager %}
2 | # built frontend assets with {{ node_package_manager }} and {{ node_build_tool }}
3 | FROM node:lts{{ alpine ? '-alpine' : '' }} AS node
4 | WORKDIR /app
5 |
6 | {# install node dependencies #}
7 | {% if node_package_manager == 'npm' %}
8 | {# install dependencies with npm #}
9 | COPY /package.json /package-lock.json /app/
10 | RUN npm ci
11 | {% elseif node_package_manager == 'yarn' %}
12 | {# install dependencies with yarn #}
13 | COPY /package.json /yarn.lock /app/
14 | RUN yarn install
15 | {% endif %} {# end of node_package_manager switch #}
16 |
17 | COPY /resources/ /app/resources/
18 |
19 | {# build assets #}
20 | {% if node_build_tool == 'vite' %}
21 | COPY /*.js /*.ts /app/
22 | {# build with vite #}
23 | {% if node_package_manager == 'npm' %}
24 | RUN npm run build
25 | {% elseif node_package_manager == 'yarn' %}
26 | RUN yarn run build
27 | {% endif %}
28 | {% elseif node_build_tool == 'mix' %}
29 | COPY /*.js /*.ts /app/
30 | {# build with mix #}
31 | {% if node_package_manager == 'npm' %}
32 | RUN npm run production
33 | {% elseif node_package_manager == 'yarn' %}
34 | RUN yarn run production
35 | {% endif %} {# end of node_package_manager switch #}
36 | {% endif %} {# end of node_build_tool #}
37 | {% endif %} {# end of node_package_manager if #}
--------------------------------------------------------------------------------
/resources/templates/partials/node-copy-assets.dockerfile.twig:
--------------------------------------------------------------------------------
1 | {% if node_build_tool %}
2 | # copy {{ node_build_tool }} assets to nginx image
3 | {% if node_build_tool == 'vite' %}
4 | {# only copy assets if npm build is enabled #}
5 | COPY --from=node /app/public/build/ /app/public/build/
6 | {% elseif node_build_tool == 'mix' %}
7 | COPY --from=node /app/public/css/ /app/public/css/
8 | COPY --from=node /app/public/js/ /app/public/js/
9 | COPY --from=node /app/public/fonts/ /app/public/fonts/
10 | {% endif %} {# end of node_build_tool switch #}
11 | {% endif %} {# end of node_build_tool if #}
--------------------------------------------------------------------------------
/resources/templates/php.dockerfile.twig:
--------------------------------------------------------------------------------
1 | # Composer installation
2 | FROM php:{{ php_version }}-fpm{{ alpine ? '-alpine' : '' }} AS composer
3 | WORKDIR /app
4 |
5 | ## install composer dependencies
6 | COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
7 | RUN install-php-extensions @composer
8 | COPY / /app
9 | RUN composer install --optimize-autoloader --no-dev
10 |
11 | {# import node partial, exposed as "node" layer #}
12 | {% include 'partials/node-build-assets.dockerfile.twig' %}
13 |
14 | # build php-fpm image
15 | FROM php:{{ php_version }}-fpm{{ alpine ? '-alpine' : '' }}
16 | WORKDIR /app
17 |
18 | # install required extensions
19 | COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
20 | RUN install-php-extensions {{ php_extensions }}
21 |
22 | # copy dependencies from composer installation step
23 | COPY --chown=www-data:www-data --from=composer /app/vendor/ /app/vendor/
24 |
25 | {# import node-copy-assets partial, copy assets from node layer #}
26 | {% include 'partials/node-copy-assets.dockerfile.twig' %}
27 |
28 | # copy application source code
29 | COPY --chown=www-data:www-data / /app
30 |
31 | {% if artisan_optimize %}
32 | # optimize application before start
33 | RUN echo "php artisan optimize --no-ansi && php-fpm" >> /usr/bin/entrypoint.sh && \
34 | chmod +x /usr/bin/entrypoint.sh
35 | {% else %}
36 | # add entrypoint
37 | RUN echo "php-fpm" >> /usr/bin/entrypoint.sh && \
38 | chmod +x /usr/bin/entrypoint.sh
39 | {% endif %}
40 |
41 | CMD ["/usr/bin/entrypoint.sh"]
42 |
--------------------------------------------------------------------------------
/src/Commands/BaseCommand.php:
--------------------------------------------------------------------------------
1 | choice(
19 | question: $question,
20 | choices: array_merge($choices, [self::NONE]),
21 | default: $default,
22 | );
23 |
24 | return $choice === self::NONE ? false : $choice;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Commands/BaseDockerCommand.php:
--------------------------------------------------------------------------------
1 | $config->get('docker-builder.tags.nginx'),
16 | 'DOCKER_PHP_TAG' => $config->get('docker-builder.tags.php'),
17 | ];
18 | }
19 |
20 | public function runProcess(Process $process, $out = STDOUT, $err = STDERR): int
21 | {
22 | return $process->run(function ($type, $buffer) use ($out, $err) {
23 | match ($type) {
24 | Process::OUT => fwrite($out, $buffer),
25 | Process::ERR => fwrite($err, $buffer),
26 | };
27 | });
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Commands/DockerBuildCommand.php:
--------------------------------------------------------------------------------
1 | getEnvironment(),
21 | timeout: null,
22 | );
23 |
24 | return $this->runProcess($process);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Commands/DockerCiCommand.php:
--------------------------------------------------------------------------------
1 | argument('ci-platform')) {
19 | if (! in_array($argument, CiPlatform::values())) {
20 | $this->error("Invalid value [$argument] for argument [ci-platform].");
21 |
22 | return self::FAILURE;
23 | }
24 |
25 | return $this->copy($argument);
26 | }
27 |
28 | $detected = app(CiPlatformDetector::class)->detect();
29 |
30 | if ($detected) {
31 | return $this->copy($detected);
32 | }
33 |
34 | $this->warn('Unfortunately, no CI platform could be detected.');
35 | $this->warn('Please use the [ci-platform] argument to manually define a supported platform.');
36 |
37 | return self::FAILURE;
38 | }
39 |
40 | protected function copy(string $platform): int
41 | {
42 | if ($platform === CiPlatform::GITHUB_ACTIONS) {
43 | $template = package_path('resources/templates/ci-platforms/github-workflow.yml');
44 | $output = base_path('.github/workflows/ci.yml');
45 | } elseif ($platform === CiPlatform::GITLAB_CI) {
46 | $template = package_path('resources/templates/ci-platforms/.gitlab-ci.yml');
47 | $output = base_path('.gitlab-ci.yml');
48 | } else {
49 | $this->error('Invalid platform passed to '.__METHOD__.' this should never happen.');
50 |
51 | return self::INVALID;
52 | }
53 |
54 | $fromBasePath = str($output)->after(base_path().'/')->value();
55 |
56 | if (File::isFile($output)) {
57 | $this->info(sprintf(
58 | 'Using [%s], but [%s] file already exists.',
59 | CiPlatform::name($platform),
60 | $fromBasePath,
61 | ));
62 |
63 | return self::SUCCESS;
64 | }
65 |
66 | $this->info(sprintf('Using [%s], copying [%s] to [%s].', CiPlatform::name($platform), $fromBasePath, dirname($output)));
67 |
68 | File::copy($template, $output);
69 |
70 | return self::SUCCESS;
71 | }
72 |
73 | protected function getArguments(): array
74 | {
75 | return [
76 | new InputArgument(
77 | name: 'ci-platform',
78 | mode: InputArgument::OPTIONAL,
79 | description: 'CI platform (supported: github, gitlab)',
80 | ),
81 | ];
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/Commands/DockerGenerateCommand.php:
--------------------------------------------------------------------------------
1 | getAnswer($this)),
40 | phpExtensions: $phpExtensionsQuestion->getAnswer($this, $phpVersion),
41 | artisanOptimize: $artisanOptimizeQuestion->getAnswer($this),
42 | alpine: $alpineQuestion->getAnswer($this),
43 | nodePackageManager: $nodePackageManager = $nodePackageManagerQuestion->getAnswer($this),
44 | nodeBuildTool: $nodePackageManager ? $nodeBuildToolQuestion->getAnswer($this) : false,
45 | );
46 | } catch (InvalidOptionValueException $exception) {
47 | $this->error($exception->getMessage());
48 |
49 | return self::INVALID;
50 | }
51 |
52 | $this->printConfigurationTable($config);
53 | $this->newLine();
54 |
55 | if (! $this->option('no-interaction') && ! $this->confirm('Does this look correct?', true)) {
56 | $this->comment('Exiting.');
57 |
58 | return self::SUCCESS;
59 | }
60 |
61 | $this->saveDockerfiles($config);
62 | $this->newLine();
63 |
64 | $this->info('Command to generate above configuration:');
65 | $this->comment(sprintf(' %s', implode(' ', $config->getCommand())));
66 |
67 | return self::SUCCESS;
68 | }
69 |
70 | public function printConfigurationTable(Configuration $config): void
71 | {
72 | $this->info('Configuration:');
73 |
74 | $this->table(['Key', 'Value'], [
75 | ['PHP version',
76 | ''.$config->getPhpVersion()->label().'',
77 | ],
78 | ['PHP extensions',
79 | implode(', ', $config->getPhpExtensions()),
80 | ],
81 | ['Artisan Optimize',
82 | ''.json_encode($config->isArtisanOptimize()).'',
83 | ],
84 | ['Alpine images',
85 | ''.json_encode($config->isAlpine()).'',
86 | ],
87 | ['Node package manager',
88 | ($nodePackageManager = $config->getNodePackageManager())
89 | ? NodePackageManager::name($nodePackageManager)
90 | : 'None',
91 | ],
92 | ['Node build tool',
93 | $config->getNodePackageManager()
94 | ? NodeBuildTool::name($config->getNodeBuildTool())
95 | : 'None',
96 | ],
97 | ]);
98 | }
99 |
100 | private function saveDockerfiles(Configuration $config): void
101 | {
102 | if (! is_dir($dir = base_path('.docker'))) {
103 | mkdir($dir);
104 | }
105 |
106 | $this->info('Saving Dockerfiles:');
107 |
108 | $context = [
109 | 'php_version' => $config->getPhpVersion()->label(),
110 | 'php_extensions' => implode(' ', $config->getPhpExtensions()),
111 | 'artisan_optimize' => $config->isArtisanOptimize(),
112 | 'alpine' => $config->isAlpine(),
113 | 'node_package_manager' => $config->getNodePackageManager(),
114 | 'node_build_tool' => $config->getNodeBuildTool(),
115 | ];
116 |
117 | $dockerfiles = [
118 | 'php.dockerfile' => $this->render('php.dockerfile.twig', $context),
119 | 'nginx.dockerfile' => $this->render('nginx.dockerfile.twig', $context),
120 | ];
121 |
122 | foreach ($dockerfiles as $file => $content) {
123 | // Example: $PWD/.docker/{php,nginx}.dockerfile
124 | $dockerfile = sprintf('%s/%s', $dir, $file);
125 |
126 | // Save Dockerfile contents
127 | File::put(
128 | path: $dockerfile,
129 | contents: $content,
130 | );
131 |
132 | // Output saved Dockerfile location
133 | $filename = str($dockerfile)->after(base_path())->trim('/');
134 | $this->comment(sprintf(' Saved: %s', $filename));
135 | }
136 | }
137 |
138 | protected function getOptions(): array
139 | {
140 | return [
141 | new InputOption(
142 | name: 'detect',
143 | shortcut: 'd',
144 | mode: InputOption::VALUE_NONE,
145 | description: 'Detect project requirements'
146 | ),
147 | new InputOption(
148 | name: 'php-version',
149 | shortcut: 'p',
150 | mode: InputOption::VALUE_REQUIRED,
151 | description: sprintf('PHP version (supported: %s)', implode(', ', PhpVersion::values())),
152 | ),
153 | new InputOption(
154 | name: 'php-extensions',
155 | shortcut: 'e',
156 | mode: InputOption::VALUE_REQUIRED,
157 | description: sprintf('PHP extensions (supported: %s)', implode(', ', PhpExtensions::values())),
158 | ),
159 | new InputOption(
160 | name: 'optimize',
161 | shortcut: 'o',
162 | mode: InputOption::VALUE_NEGATABLE,
163 | description: 'Add "php artisan optimize" to entrypoint',
164 | ),
165 | new InputOption( // TODO: implement opcache extension
166 | name: 'opcache',
167 | mode: InputOption::VALUE_NEGATABLE,
168 | description: 'Add "opcache" extension and configure it',
169 | default: true,
170 | ),
171 | new InputOption(
172 | name: 'alpine',
173 | shortcut: 'a',
174 | mode: InputOption::VALUE_NEGATABLE,
175 | description: 'Use Alpine Linux based images',
176 | ),
177 | new InputOption(
178 | name: 'node-package-manager',
179 | shortcut: 'm',
180 | mode: InputOption::VALUE_REQUIRED,
181 | description: sprintf('Node Package Manager (supported: %s)', implode(', ', NodePackageManager::values())),
182 | ),
183 | new InputOption(
184 | name: 'node-build-tool',
185 | shortcut: 'b',
186 | mode: InputOption::VALUE_REQUIRED,
187 | description: sprintf('Node Build Tool (supported: %s)', implode(', ', NodeBuildTool::values())),
188 | ),
189 | ];
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/src/Commands/DockerPushCommand.php:
--------------------------------------------------------------------------------
1 | getEnvironment(),
21 | timeout: null,
22 | );
23 |
24 | return $this->runProcess($process);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/AlpineQuestion.php:
--------------------------------------------------------------------------------
1 | option('alpine') === false) {
12 | return false;
13 | }
14 |
15 | if ($command->option('alpine') || $command->option('detect')) {
16 | return true;
17 | }
18 |
19 | return $command->confirm(
20 | question: 'Do you want to use "Alpine Linux" based images?',
21 | default: true
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/ArtisanOptimizeQuestion.php:
--------------------------------------------------------------------------------
1 | option('optimize') === false) {
12 | return false;
13 | }
14 |
15 | if ($command->option('optimize') || $command->option('detect')) {
16 | return true;
17 | }
18 |
19 | return $command->confirm(
20 | question: 'Do you want to run "php artisan optimize" when the image boots?',
21 | default: true,
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/BaseQuestion.php:
--------------------------------------------------------------------------------
1 | 'GitHub Actions',
23 | self::GITLAB_CI => 'GitLab CI/CD',
24 | };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/Choices/NodeBuildTool.php:
--------------------------------------------------------------------------------
1 | 'Vite.js',
23 | self::MIX => 'Laravel Mix',
24 | };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/Choices/NodePackageManager.php:
--------------------------------------------------------------------------------
1 | 'NPM',
23 | self::YARN => 'Yarn',
24 | };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/Choices/PhpExtensions.php:
--------------------------------------------------------------------------------
1 | get($phpVersion);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/Choices/PhpVersion.php:
--------------------------------------------------------------------------------
1 | $version->value, self::cases());
14 | }
15 |
16 | public function label(): string
17 | {
18 | return $this->value;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/NodeBuildToolQuestion.php:
--------------------------------------------------------------------------------
1 | option('node-build-tool')) {
21 | return in_array($option, NodeBuildTool::values())
22 | ? $option
23 | : throw new InvalidOptionValueException("Invalid value [$option] for option [node-build-tool].");
24 | }
25 |
26 | $detected = app(NodeBuildToolDetector::class)->detect();
27 |
28 | if ($detected && $command->option('detect')) {
29 | return $detected;
30 | }
31 |
32 | return $command->choice(
33 | question: 'Which Node build tool do you use?',
34 | choices: NodeBuildTool::values(),
35 | default: $detected ?: NodeBuildTool::VITE,
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/NodePackageManagerQuestion.php:
--------------------------------------------------------------------------------
1 | option('node-package-manager')) {
21 | return in_array($option, NodePackageManager::values())
22 | ? $option
23 | : throw new InvalidOptionValueException("Invalid value [$option] for option [node-package-manager].");
24 | }
25 |
26 | $detected = app(NodePackageManagerDetector::class)->detect();
27 |
28 | if ($detected && $command->option('detect')) {
29 | return $detected;
30 | }
31 |
32 | return $command->optionalChoice(
33 | question: 'Which Node package manager do you use?',
34 | choices: NodePackageManager::values(),
35 | default: $detected ?: NodePackageManager::NPM,
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/PhpExtensionsQuestion.php:
--------------------------------------------------------------------------------
1 | option('php-extensions')) {
29 | $extensions = explode(',', $option);
30 |
31 | foreach ($extensions as $extension) {
32 | if (in_array($extension, $supported)) {
33 | continue;
34 | }
35 |
36 | throw new InvalidOptionValueException("Extension [$extension] is not supported.");
37 | }
38 |
39 | return array_intersect($extensions, $supported);
40 | }
41 |
42 | $detected = $this->phpExtensionsDetector
43 | ->supported($supported)
44 | ->detect();
45 |
46 | if ($command->option('detect')) {
47 | return $detected;
48 | }
49 |
50 | $default = collect($detected)
51 | ->map(fn ($extension) => array_search($extension, $supported))
52 | ->where(fn ($key) => is_int($key))
53 | ->join(',');
54 |
55 | return $command->choice(
56 | question: 'PHP extensions',
57 | choices: $supported,
58 | default: $default,
59 | multiple: true,
60 | );
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Commands/GenerateQuestions/PhpVersionQuestion.php:
--------------------------------------------------------------------------------
1 | option('php-version')) {
26 | return in_array($option, PhpVersion::values())
27 | ? $option
28 | : throw new InvalidOptionValueException("Invalid value [$option] for option [php-version].");
29 | }
30 |
31 | $detected = $this->phpVersionDetector->detect();
32 |
33 | if ($detected !== false && $command->option('detect')) {
34 | return PhpVersion::from($detected)->label();
35 | }
36 |
37 | return $command->choice(
38 | question: 'PHP version',
39 | choices: PhpVersion::values(),
40 | default: $detected ?: PhpVersion::v8_3->label(),
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Detectors/CiPlatformDetector.php:
--------------------------------------------------------------------------------
1 | getPathMapping() as $file => $tool) {
12 | if (File::isFile($file)) {
13 | return $tool;
14 | }
15 | }
16 |
17 | return false;
18 | }
19 |
20 | /**
21 | * @return array
22 | */
23 | abstract public function getPathMapping(): array;
24 | }
25 |
--------------------------------------------------------------------------------
/src/Detectors/NodeBuildToolDetector.php:
--------------------------------------------------------------------------------
1 | NodeBuildTool::VITE,
13 | base_path('vite.config.ts') => NodeBuildTool::VITE,
14 | base_path('webpack.mix.js') => NodeBuildTool::MIX,
15 | ];
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Detectors/NodePackageManagerDetector.php:
--------------------------------------------------------------------------------
1 | NodePackageManager::NPM,
13 | base_path('yarn.lock') => NodePackageManager::YARN,
14 | ];
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Detectors/PhpExtensionsDetector.php:
--------------------------------------------------------------------------------
1 | supported;
16 | }
17 |
18 | $this->supported = $supported;
19 |
20 | return $this;
21 | }
22 |
23 | public function detect(): array
24 | {
25 | $extensions = [
26 | $this->getDefaultExtensions(),
27 | $this->getCacheExtensions(),
28 | $this->getDatabaseExtensions(),
29 | $this->getBroadcastingExtensions(),
30 | $this->getQueueExtensions(),
31 | $this->getSessionExtensions(),
32 | ];
33 |
34 | return Collection::make($extensions)
35 | ->flatten()
36 | ->unique()
37 | ->sort()
38 | ->intersect($this->supported())
39 | ->values()
40 | ->toArray();
41 | }
42 |
43 | /**
44 | * @return string[]
45 | */
46 | public function getDefaultExtensions(): array
47 | {
48 | return ['bcmath'];
49 | }
50 |
51 | /**
52 | * @return string[]
53 | */
54 | public function getCacheExtensions(): array
55 | {
56 | $store = config('cache.default');
57 | $driver = config("cache.stores.$store.driver");
58 |
59 | return Arr::wrap(match ($driver) {
60 | 'apc' => 'apcu',
61 | 'memcached' => 'memcached',
62 | 'redis' => 'redis',
63 | default => [],
64 | });
65 | }
66 |
67 | /**
68 | * @return string[]
69 | */
70 | public function getDatabaseExtensions(): array
71 | {
72 | $connection = config('database.default');
73 | $driver = config("database.connections.$connection.driver");
74 |
75 | return Arr::wrap(match ($driver) {
76 | 'mysql' => 'pdo_mysql',
77 | 'pgsql' => 'pdo_pgsql',
78 | 'sqlsrv' => ['pdo_sqlsrv', 'sqlsrv'],
79 | default => [],
80 | });
81 | }
82 |
83 | /**
84 | * @return string[]
85 | */
86 | public function getBroadcastingExtensions(): array
87 | {
88 | $connection = config('broadcasting.default');
89 | $driver = config("broadcasting.connections.$connection.driver");
90 |
91 | return Arr::wrap(match ($driver) {
92 | 'redis' => 'redis',
93 | default => [],
94 | });
95 | }
96 |
97 | /**
98 | * @return string[]
99 | */
100 | public function getQueueExtensions(): array
101 | {
102 | $connection = config('queue.default');
103 | $driver = config("queue.connections.$connection.driver");
104 |
105 | return Arr::wrap(match ($driver) {
106 | 'redis' => 'redis',
107 | default => [],
108 | });
109 | }
110 |
111 | /**
112 | * @return string[]
113 | */
114 | public function getSessionExtensions(): array
115 | {
116 | $store = config('session.driver');
117 | $driver = config("cache.stores.$store.driver");
118 |
119 | return Arr::wrap(match ($driver) {
120 | 'apc' => 'apcu',
121 | 'memcached' => 'memcached',
122 | 'redis' => 'redis',
123 | default => [],
124 | });
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/Detectors/PhpVersionDetector.php:
--------------------------------------------------------------------------------
1 | getComposerFileContents();
16 | if ($composer === false) {
17 | return false;
18 | }
19 |
20 | $composer = json_decode($composer);
21 | $php = data_get($composer, 'require.php');
22 | if (! is_string($php)) {
23 | return false;
24 | }
25 |
26 | $php = app(VersionParser::class)
27 | ->parseConstraints($php)
28 | ->getLowerBound()
29 | ->getVersion();
30 |
31 | return Arr::first(
32 | array: PhpVersion::values(),
33 | callback: fn ($value) => Str::startsWith($php, $value),
34 | default: false,
35 | );
36 | }
37 |
38 | public function getComposerFileContents(): string|false
39 | {
40 | if (File::missing($path = base_path('composer.json'))) {
41 | return false;
42 | }
43 |
44 | return File::get($path);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/DockerServiceProvider.php:
--------------------------------------------------------------------------------
1 | app->singleton(SupportedPhpExtensions::class);
17 | }
18 |
19 | public function boot(): void
20 | {
21 | if ($this->app->runningInConsole()) {
22 | $this->commands([
23 | DockerBuildCommand::class,
24 | DockerCiCommand::class,
25 | DockerGenerateCommand::class,
26 | DockerPushCommand::class,
27 | ]);
28 | }
29 |
30 | $this->publishes([
31 | dirname(__DIR__).'/config/docker-builder.php' => config_path('docker-builder.php'),
32 | ]);
33 |
34 | $this->mergeConfigFrom(
35 | dirname(__DIR__).'/config/docker-builder.php', 'docker-builder',
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidOptionValueException.php:
--------------------------------------------------------------------------------
1 | cache)) {
17 | return $this->cache;
18 | }
19 |
20 | $contents = $this->fetch();
21 |
22 | if ($contents === false) {
23 | return [];
24 | }
25 |
26 | return $this->cache = collect($contents)
27 | ->filter(fn (string $extension): bool => is_null($phpVersion) || str($extension)->contains($phpVersion->label()))
28 | ->map(fn (string $extension): string => str($extension)->trim()->before(' '))
29 | ->filter()
30 | ->values()
31 | ->toArray();
32 | }
33 |
34 | public function fetch(): array|false
35 | {
36 | $response = rescue(
37 | callback: fn () => Http::get(self::URL),
38 | rescue: false,
39 | );
40 |
41 | if ($response === false || $response->failed()) {
42 | return false;
43 | }
44 |
45 | return array_filter(explode("\n", $response->body()));
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Objects/Configuration.php:
--------------------------------------------------------------------------------
1 | phpVersion;
25 | }
26 |
27 | /**
28 | * @return string[]
29 | */
30 | public function getPhpExtensions(): array
31 | {
32 | return $this->phpExtensions;
33 | }
34 |
35 | public function isArtisanOptimize(): bool
36 | {
37 | return $this->artisanOptimize;
38 | }
39 |
40 | public function isAlpine(): bool
41 | {
42 | return $this->alpine;
43 | }
44 |
45 | public function getNodePackageManager(): string|false
46 | {
47 | return $this->nodePackageManager;
48 | }
49 |
50 | public function getNodeBuildTool(): string|false
51 | {
52 | return $this->nodeBuildTool;
53 | }
54 |
55 | /**
56 | * @return string[]
57 | */
58 | public function getCommand(): array
59 | {
60 | return array_values(array_filter([
61 | 'php', 'artisan', 'docker:generate',
62 | '-n', // --no-interaction
63 | '-p '.$this->getPhpVersion()->label(), // --php-version
64 | '-e '.implode(',', $this->getPhpExtensions()), // --php-extensions
65 | $this->isArtisanOptimize() ? '-o' : null, // --optimize
66 | $this->isAlpine() ? '-a' : null, // --alpine
67 | $this->getNodePackageManager() ? '-m '.$this->getNodePackageManager() : null, // --node-package-manager
68 | $this->getNodePackageManager() ? '-b '.$this->getNodeBuildTool() : null, // --node-build-tool
69 | ]));
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/Traits/InteractsWithTwig.php:
--------------------------------------------------------------------------------
1 | twig)) {
18 | return $this->twig;
19 | }
20 |
21 | $path = package_path('resources/templates');
22 | $loader = new FilesystemLoader($path);
23 |
24 | return $this->twig = new TwigEnvironment($loader);
25 | }
26 |
27 | /**
28 | * Render a Twig template.
29 | *
30 | * @param array $context
31 | *
32 | * @throws LoaderError when the template cannot be found
33 | * @throws SyntaxError when an error occurred during compilation
34 | * @throws RuntimeError when an error occurred during rendering
35 | */
36 | public function render(string $name, array $context): string
37 | {
38 | return $this->twig()->render($name, $context);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/helpers.php:
--------------------------------------------------------------------------------
1 | createPartialMock(BaseCommand::class, ['choice']);
18 | $mock->expects($this->once())
19 | ->method('choice')
20 | ->with('question', ['an-option', 'none'], 'an-option')
21 | ->willReturn('none');
22 |
23 | $answer = $mock->optionalChoice('question', ['an-option'], 'an-option');
24 |
25 | self::assertFalse($answer);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/Commands/BaseDockerCommandTest.php:
--------------------------------------------------------------------------------
1 | mock('config', function (MockInterface $mock) {
20 | $mock->shouldReceive('get')
21 | ->once()
22 | ->with('docker-builder.tags.nginx')
23 | ->andReturn('test:nginx');
24 | $mock->shouldReceive('get')
25 | ->once()
26 | ->with('docker-builder.tags.php')
27 | ->andReturn('test:php');
28 | });
29 |
30 | $class = $this->newBaseDockerCommand();
31 | $environment = $class->getEnvironment();
32 |
33 | self::assertEquals([
34 | 'DOCKER_NGINX_TAG' => 'test:nginx',
35 | 'DOCKER_PHP_TAG' => 'test:php',
36 | ], $environment);
37 | }
38 |
39 | public function testItRunsProcess(): void
40 | {
41 | $mock = $this->createMock(Process::class);
42 | $mock->expects($this->once())
43 | ->method('run')
44 | ->willReturnCallback(function ($callable) {
45 | $callable(Process::OUT, "stdout output\n");
46 | $callable(Process::ERR, "stderr output\n");
47 |
48 | return 0;
49 | });
50 |
51 | $class = $this->newBaseDockerCommand();
52 | $output = $class->runProcess(
53 | process: $mock,
54 | out: fopen('php://memory', 'r+'),
55 | err: fopen('php://memory', 'r+'),
56 | );
57 |
58 | self::assertEquals(0, $output);
59 | }
60 |
61 | private function newBaseDockerCommand(): BaseDockerCommand
62 | {
63 | return new class extends BaseDockerCommand
64 | {
65 | };
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/tests/Commands/DockerBuildCommandTest.php:
--------------------------------------------------------------------------------
1 | createPartialMock(DockerBuildCommand::class, [
20 | 'getEnvironment',
21 | 'runProcess',
22 | ]);
23 |
24 | $environment = [
25 | 'DOCKER_NGINX_TAG' => 'test:nginx',
26 | 'DOCKER_PHP_TAG' => 'test:php',
27 | ];
28 |
29 | $mock->expects($this->once())
30 | ->method('getEnvironment')
31 | ->willReturn($environment);
32 |
33 | $mock->expects($this->once())
34 | ->method('runProcess')
35 | ->with($this->callback(
36 | function (Process $process) use ($environment): bool {
37 | $command = "'".package_path('bin/docker-build')."'";
38 |
39 | return $process->getCommandLine() === $command
40 | && $process->getEnv() === $environment;
41 | }
42 | ))
43 | ->willReturn(0);
44 |
45 | $output = $mock->handle();
46 |
47 | self::assertEquals(0, $output);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/Commands/DockerCiCommandTest.php:
--------------------------------------------------------------------------------
1 | artisan('docker:ci gitlab')
48 | ->expectsOutput('Using [GitLab CI/CD], but [.gitlab-ci.yml] file already exists.')
49 | ->assertSuccessful();
50 | }
51 |
52 | public function testItChecksIfGithubFileExists(): void
53 | {
54 | File::ensureDirectoryExists(base_path('.github/workflows'));
55 | touch(base_path('.github/workflows/ci.yml'));
56 |
57 | $this->artisan('docker:ci github')
58 | ->expectsOutput('Using [GitHub Actions], but [.github/workflows/ci.yml] file already exists.')
59 | ->assertSuccessful();
60 | }
61 |
62 | public function testItUsesCiPlatformDetector(): void
63 | {
64 | $this->mock(CiPlatformDetector::class, function (MockInterface $mock) {
65 | $mock->expects('detect')
66 | ->once()
67 | ->withNoArgs()
68 | ->andReturn(CiPlatform::GITLAB_CI);
69 | });
70 |
71 | $this->artisan('docker:ci')
72 | ->expectsOutput(sprintf('Using [GitLab CI/CD], copying [.gitlab-ci.yml] to [%s].', base_path()))
73 | ->assertSuccessful();
74 |
75 | $this->assertFileExists(base_path('.gitlab-ci.yml'));
76 | }
77 |
78 | public function testItTellsUserToManuallyPassAPlatform(): void
79 | {
80 | $this->mock(CiPlatformDetector::class, function (MockInterface $mock) {
81 | $mock->expects('detect')
82 | ->once()
83 | ->withNoArgs()
84 | ->andReturn(false);
85 | });
86 |
87 | $this->artisan('docker:ci')
88 | ->expectsOutput('Unfortunately, no CI platform could be detected.')
89 | ->expectsOutput('Please use the [ci-platform] argument to manually define a supported platform.')
90 | ->assertFailed();
91 | }
92 |
93 | public function testItThrowsErrorWhenDetectorReturnsInvalid(): void
94 | {
95 | $this->mock(CiPlatformDetector::class, function (MockInterface $mock) {
96 | $mock->expects('detect')
97 | ->once()
98 | ->withNoArgs()
99 | ->andReturn('nonsense');
100 | });
101 |
102 | $this->artisan('docker:ci')
103 | ->expectsOutput('Invalid platform passed to BlameButton\LaravelDockerBuilder\Commands\DockerCiCommand::copy this should never happen.')
104 | ->assertExitCode(Command::INVALID);
105 | }
106 |
107 | public function testItErrorsOnInvalidArgument(): void
108 | {
109 | $this->artisan('docker:ci nonsense')
110 | ->expectsOutput('Invalid value [nonsense] for argument [ci-platform].')
111 | ->assertFailed();
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/tests/Commands/DockerGenerateCommandTest.php:
--------------------------------------------------------------------------------
1 | mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
48 | $mock->shouldReceive('get')->withAnyArgs()->andReturn([
49 | 'bcmath', 'pdo_mysql', 'pdo_pgsql', 'redis', 'apcu',
50 | ]);
51 | });
52 | }
53 |
54 | public static function provideCommands(): array
55 | {
56 | return [
57 | '8.2, pgsql, redis, optimize, alpine, npm, vite' => [
58 | [
59 | "FROM php:8.2-fpm-alpine AS composer\n",
60 | "FROM node:lts-alpine AS node\n",
61 | 'COPY /package.json /package-lock.json /app/',
62 | 'COPY /package.json /package-lock.json /app/',
63 | "npm ci\n",
64 | "RUN npm run build\n",
65 | "RUN install-php-extensions bcmath pdo_pgsql redis\n",
66 | "COPY --from=node /app/public/build/ /app/public/build/\n",
67 | 'RUN echo "php artisan optimize --no-ansi && php-fpm" >> /usr/bin/entrypoint.sh',
68 | "CMD [\"/usr/bin/entrypoint.sh\"]\n",
69 | ],
70 | 'docker:generate -n -p 8.2 -e bcmath,pdo_pgsql,redis -o -a -m npm -b vite',
71 | ],
72 | '8.1, mysql, apcu, no optimize, no alpine, yarn, mix' => [
73 | [
74 | "FROM php:8.1-fpm AS composer\n",
75 | "FROM node:lts AS node\n",
76 | 'COPY /package.json /yarn.lock /app/',
77 | "RUN yarn install\n",
78 | "COPY /*.js /*.ts /app/\n",
79 | "RUN yarn run production\n",
80 | "RUN install-php-extensions bcmath pdo_mysql apcu\n",
81 | "COPY --from=node /app/public/css/ /app/public/css/\n",
82 | "COPY --from=node /app/public/js/ /app/public/js/\n",
83 | "COPY --from=node /app/public/fonts/ /app/public/fonts/\n",
84 | 'RUN echo "php-fpm" >> /usr/bin/entrypoint.sh',
85 | "CMD [\"/usr/bin/entrypoint.sh\"]\n",
86 | ],
87 | 'docker:generate -n -p 8.1 -e bcmath,pdo_mysql,apcu --no-optimize --no-alpine -m yarn -b mix',
88 | ],
89 | ];
90 | }
91 |
92 | #[DataProvider('provideCommands')]
93 | public function testItGeneratesConfigurations(array $expected, string $command): void
94 | {
95 | File::deleteDirectory(base_path('.docker'));
96 |
97 | $this->artisan($command);
98 |
99 | $contents = file_get_contents(base_path('.docker/php.dockerfile'));
100 |
101 | foreach ($expected as $assertion) {
102 | self::assertStringContainsString($assertion, $contents);
103 | }
104 | }
105 |
106 | public static function provideIsInformationCorrectAnswer(): array
107 | {
108 | return [
109 | ['yes'],
110 | ['no'],
111 | ];
112 | }
113 |
114 | #[DataProvider('provideIsInformationCorrectAnswer')]
115 | public function testItAsksQuestions(string $isCorrect): void
116 | {
117 | $this->mock(SupportedPhpExtensions::class, function (SupportedPhpExtensions&MockInterface $mock) {
118 | $mock->shouldReceive('get')->with(PhpVersion::v8_3)->once()->andReturn(['bcmath', 'redis']);
119 | $mock->shouldReceive('get')->with(null)->andReturn(['not the same as with 8.3']);
120 | });
121 |
122 | $command = $this->artisan('docker:generate');
123 | $command->expectsChoice('PHP version', '8.3', ['8.3', '8.2', '8.1']);
124 | $command->expectsChoice('PHP extensions', ['bcmath', 'redis'], ['bcmath', 'redis']);
125 | $command->expectsConfirmation('Do you want to run "php artisan optimize" when the image boots?', 'yes');
126 | $command->expectsConfirmation('Do you want to use "Alpine Linux" based images?', 'yes');
127 | $command->expectsChoice('Which Node package manager do you use?', 'npm', ['npm', 'yarn', 'none']);
128 | $command->expectsChoice('Which Node build tool do you use?', 'vite', ['vite', 'mix']);
129 | $command->expectsConfirmation('Does this look correct?', $isCorrect);
130 | if ($isCorrect === 'yes') {
131 | $command->expectsOutput('Configuration:');
132 | $command->expectsTable(['Key', 'Value'], [
133 | ['PHP version', '8.3'],
134 | ['PHP extensions', 'bcmath, redis'],
135 | ['Artisan Optimize', 'true'],
136 | ['Alpine images', 'true'],
137 | ['Node package manager', 'NPM'],
138 | ['Node build tool', 'Vite.js'],
139 | ]);
140 | $command->expectsOutput('Command to generate above configuration:');
141 | $command->expectsOutput(' php artisan docker:generate -n -p 8.3 -e bcmath,redis -o -a -m npm -b vite');
142 | } else {
143 | $command->expectsOutput('Exiting.');
144 | }
145 | $command->assertSuccessful();
146 | }
147 |
148 | public static function provideInvalidOptions(): array
149 | {
150 | return [
151 | 'php version' => [
152 | 'Invalid value [unsupported] for option [php-version].',
153 | 'docker:generate -n -p unsupported -e bcmath -o -a -m npm -b vite',
154 | ],
155 | 'php extensions' => [
156 | 'Extension [unsupported] is not supported.',
157 | 'docker:generate -n -p 8.2 -e bcmath,unsupported -o -a -m npm -b vite',
158 | ],
159 | 'node package manager' => [
160 | 'Invalid value [unsupported] for option [node-package-manager].',
161 | 'docker:generate -n -p 8.2 -e bcmath -o -a -m unsupported -b vite',
162 | ],
163 | 'node build tool' => [
164 | 'Invalid value [unsupported] for option [node-build-tool].',
165 | 'docker:generate -n -p 8.2 -e bcmath -o -a -m npm -b unsupported',
166 | ],
167 | ];
168 | }
169 |
170 | #[DataProvider('provideInvalidOptions')]
171 | public function testItThrowsExceptions(string $expected, string $command): void
172 | {
173 | $command = $this->artisan($command);
174 | $command->expectsOutput($expected);
175 | $command->assertFailed();
176 | }
177 |
178 | public function testItAcceptsNodePackageManagerNone(): void
179 | {
180 | $this->mock(PhpVersionQuestion::class, function (PhpVersionQuestion&MockInterface $mock) {
181 | $mock->shouldReceive('getAnswer')->once()->andReturn('8.2');
182 | });
183 | $this->mock(PhpExtensionsQuestion::class, function (PhpExtensionsQuestion&MockInterface $mock) {
184 | $mock->shouldReceive('getAnswer')->once()->andReturn(['bcmath']);
185 | });
186 | $this->mock(ArtisanOptimizeQuestion::class, function (ArtisanOptimizeQuestion&MockInterface $mock) {
187 | $mock->shouldReceive('getAnswer')->once()->andReturn(true);
188 | });
189 | $this->mock(AlpineQuestion::class, function (AlpineQuestion&MockInterface $mock) {
190 | $mock->shouldReceive('getAnswer')->once()->andReturn(true);
191 | });
192 | $this->mock(NodePackageManagerDetector::class, function (NodePackageManagerDetector&MockInterface $mock) {
193 | $mock->shouldReceive('detect')->once()->andReturn(false);
194 | });
195 |
196 | $this->artisan('docker:generate', ['--detect' => true])
197 | ->expectsChoice('Which Node package manager do you use?', 'none', ['npm', 'yarn', 'none'])
198 | ->expectsConfirmation('Does this look correct?', 'yes');
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/tests/Commands/DockerPushCommandTest.php:
--------------------------------------------------------------------------------
1 | createPartialMock(DockerPushCommand::class, [
20 | 'getEnvironment',
21 | 'runProcess',
22 | ]);
23 |
24 | $environment = [
25 | 'DOCKER_NGINX_TAG' => 'test:nginx',
26 | 'DOCKER_PHP_TAG' => 'test:php',
27 | ];
28 |
29 | $mock->expects($this->once())
30 | ->method('getEnvironment')
31 | ->willReturn($environment);
32 |
33 | $mock->expects($this->once())
34 | ->method('runProcess')
35 | ->with($this->callback(
36 | function (Process $process) use ($environment): bool {
37 | $command = "'".package_path('bin/docker-push')."'";
38 |
39 | return $process->getCommandLine() === $command
40 | && $process->getEnv() === $environment;
41 | }
42 | ))
43 | ->willReturn(0);
44 |
45 | $output = $mock->handle();
46 |
47 | self::assertEquals(0, $output);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/Commands/GenerateQuestions/AlpineQuestionTest.php:
--------------------------------------------------------------------------------
1 | [true, null, true],
21 | 'it returns true when alpine is true' => [true, true, null],
22 | 'it returns false when alpine is false and detect is true' => [false, false, true],
23 | 'it returns true when alpine and detect are true' => [true, true, true],
24 | ];
25 | }
26 |
27 | #[DataProvider('provideOptions')]
28 | public function testItHandlesOptionsCorrectly($expected, $alpine, $detect): void
29 | {
30 | $mock = $this->createMock(BaseCommand::class);
31 | $mock->expects($this->atMost(3))
32 | ->method('option')
33 | ->willReturnMap([
34 | ['alpine', $alpine],
35 | ['detect', $detect],
36 | ]);
37 | $mock->expects($this->never())
38 | ->method('confirm');
39 |
40 | $answer = app(AlpineQuestion::class)->getAnswer($mock);
41 |
42 | self::assertEquals($expected, $answer);
43 | }
44 |
45 | public static function provideInputs(): array
46 | {
47 | return [
48 | 'it returns true with true' => [true, true],
49 | 'it returns false with false' => [false, false],
50 | ];
51 | }
52 |
53 | #[DataProvider('provideInputs')]
54 | public function testItHandlesAnswersCorrectly($expected, $input): void
55 | {
56 | $stub = $this->createStub(BaseCommand::class);
57 | $stub->method('option')
58 | ->willReturn(null);
59 | $stub->method('confirm')
60 | ->willReturn($input);
61 |
62 | $answer = app(AlpineQuestion::class)->getAnswer($stub);
63 |
64 | self::assertEquals($expected, $answer);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/tests/Commands/GenerateQuestions/ArtisanOptimizeQuestionTest.php:
--------------------------------------------------------------------------------
1 | [true, null, true],
21 | 'it returns true when optimize is true' => [true, true, null],
22 | 'it returns false when optimize is false and detect is true' => [false, false, true],
23 | 'it returns true when optimize and detect are true' => [true, true, true],
24 | ];
25 | }
26 |
27 | #[DataProvider('provideOptions')]
28 | public function testItHandlesOptionsCorrectly($expected, $optimize, $detect): void
29 | {
30 | $mock = $this->createMock(BaseCommand::class);
31 | $mock->expects($this->atMost(3))
32 | ->method('option')
33 | ->willReturnMap([
34 | ['optimize', $optimize],
35 | ['detect', $detect],
36 | ]);
37 | $mock->expects($this->never())
38 | ->method('confirm');
39 |
40 | $answer = app(ArtisanOptimizeQuestion::class)->getAnswer($mock);
41 |
42 | self::assertEquals($expected, $answer);
43 | }
44 |
45 | public static function provideInputs(): array
46 | {
47 | return [
48 | 'it returns true with true' => [true, true],
49 | 'it returns false with false' => [false, false],
50 | ];
51 | }
52 |
53 | #[DataProvider('provideInputs')]
54 | public function testItHandlesAnswersCorrectly($expected, $input): void
55 | {
56 | $stub = $this->createStub(BaseCommand::class);
57 | $stub->method('option')
58 | ->willReturn(null);
59 | $stub->method('confirm')
60 | ->willReturn($input);
61 |
62 | $answer = app(ArtisanOptimizeQuestion::class)->getAnswer($stub);
63 |
64 | self::assertEquals($expected, $answer);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/tests/Commands/GenerateQuestions/Choices/CiPlatformTest.php:
--------------------------------------------------------------------------------
1 | mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
20 | $mock->shouldReceive('get')->once()->andReturn([
21 | 'bcmath',
22 | 'pdo_mysql',
23 | 'redis',
24 | ]);
25 | });
26 |
27 | $extensions = PhpExtensions::values();
28 |
29 | self::assertEquals(['bcmath', 'pdo_mysql', 'redis'], $extensions);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/Commands/GenerateQuestions/Choices/PhpVersionTest.php:
--------------------------------------------------------------------------------
1 | createMock(BaseCommand::class);
25 | $mock->expects($this->once())
26 | ->method('option')
27 | ->willReturnMap([
28 | ['node-build-tool', 'invalid-value'],
29 | ]);
30 |
31 | $this->expectException(InvalidOptionValueException::class);
32 |
33 | app(NodeBuildToolQuestion::class)->getAnswer($mock);
34 | }
35 |
36 | public static function provideOptions(): array
37 | {
38 | return [
39 | 'vite' => [NodeBuildTool::VITE, 'vite'],
40 | 'mix' => [NodeBuildTool::MIX, 'mix'],
41 | ];
42 | }
43 |
44 | #[DataProvider('provideOptions')]
45 | public function testItHandlesOptions($expected, $input): void
46 | {
47 | $mock = $this->createMock(BaseCommand::class);
48 | $mock->expects($this->once())
49 | ->method('option')
50 | ->willReturnMap([
51 | ['node-build-tool', $input],
52 | ]);
53 |
54 | $answer = app(NodeBuildToolQuestion::class)->getAnswer($mock);
55 |
56 | self::assertEquals($expected, $answer);
57 | }
58 |
59 | public static function provideDetectedBuildTools(): array
60 | {
61 | return [
62 | 'vite' => [NodeBuildTool::VITE, NodeBuildTool::VITE],
63 | 'mix' => [NodeBuildTool::MIX, NodeBuildTool::MIX],
64 | ];
65 | }
66 |
67 | #[DataProvider('provideDetectedBuildTools')]
68 | public function testItDetectsBuildTools($expected, $detected): void
69 | {
70 | $mock = $this->createMock(BaseCommand::class);
71 | $mock->expects($this->exactly(2))
72 | ->method('option')
73 | ->willReturnMap([
74 | ['node-build-tool', null],
75 | ['detect', true],
76 | ]);
77 |
78 | $this->mock(NodeBuildToolDetector::class, function (MockInterface $mock) use ($detected) {
79 | $mock->shouldReceive('detect')->once()->andReturn($detected);
80 | });
81 |
82 | $answer = app(NodeBuildToolQuestion::class)->getAnswer($mock);
83 |
84 | self::assertEquals($expected, $answer);
85 | }
86 |
87 | public static function provideQuestionInput(): array
88 | {
89 | return [
90 | 'vite' => ['vite', 'vite'],
91 | 'mix' => ['mix', 'mix'],
92 | ];
93 | }
94 |
95 | #[DataProvider('provideQuestionInput')]
96 | public function testItAsksQuestion($expected, $input): void
97 | {
98 | $mock = $this->createMock(BaseCommand::class);
99 | $mock->expects($this->once())
100 | ->method('option')
101 | ->willReturnMap([
102 | ['node-build-tool', null],
103 | ['detect', false],
104 | ]);
105 | $mock->expects($this->once())
106 | ->method('choice')
107 | ->willReturn($input);
108 |
109 | $this->mock(NodeBuildToolDetector::class, function (MockInterface $mock) {
110 | $mock->shouldReceive('detect')->once()->andReturn(false);
111 | });
112 |
113 | $answer = app(NodeBuildToolQuestion::class)->getAnswer($mock);
114 |
115 | self::assertEquals($expected, $answer);
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/tests/Commands/GenerateQuestions/NodePackageManagerQuestionTest.php:
--------------------------------------------------------------------------------
1 | createMock(BaseCommand::class);
25 | $mock->expects($this->once())
26 | ->method('option')
27 | ->willReturnMap([
28 | ['node-package-manager', 'invalid-value'],
29 | ]);
30 |
31 | $this->expectException(InvalidOptionValueException::class);
32 |
33 | app(NodePackageManagerQuestion::class)->getAnswer($mock);
34 | }
35 |
36 | public static function provideOptions(): array
37 | {
38 | return [
39 | 'npm' => [NodePackageManager::NPM, 'npm'],
40 | 'yarn' => [NodePackageManager::YARN, 'yarn'],
41 | ];
42 | }
43 |
44 | #[DataProvider('provideOptions')]
45 | public function testItHandlesOptions($expected, $input): void
46 | {
47 | $mock = $this->createMock(BaseCommand::class);
48 | $mock->expects($this->once())
49 | ->method('option')
50 | ->willReturnMap([
51 | ['node-package-manager', $input],
52 | ]);
53 |
54 | $answer = app(NodePackageManagerQuestion::class)->getAnswer($mock);
55 |
56 | self::assertEquals($expected, $answer);
57 | }
58 |
59 | public static function provideDetectedPackageManagers(): array
60 | {
61 | return [
62 | 'npm' => [NodePackageManager::NPM, NodePackageManager::NPM],
63 | 'yarn' => [NodePackageManager::YARN, NodePackageManager::YARN],
64 | ];
65 | }
66 |
67 | #[DataProvider('provideDetectedPackageManagers')]
68 | public function testItDetectsPackageManagers($expected, $detected): void
69 | {
70 | $mock = $this->createMock(BaseCommand::class);
71 | $mock->expects($this->exactly(2))
72 | ->method('option')
73 | ->willReturnMap([
74 | ['node-package-manager', null],
75 | ['detect', true],
76 | ]);
77 |
78 | $this->mock(NodePackageManagerDetector::class, function (MockInterface $mock) use ($detected) {
79 | $mock->shouldReceive('detect')->once()->andReturn($detected);
80 | });
81 |
82 | $answer = app(NodePackageManagerQuestion::class)->getAnswer($mock);
83 |
84 | self::assertEquals($expected, $answer);
85 | }
86 |
87 | public static function provideQuestionInput(): array
88 | {
89 | return [
90 | 'npm' => ['npm', 'npm'],
91 | 'yarn' => ['yarn', 'yarn'],
92 | ];
93 | }
94 |
95 | #[DataProvider('provideQuestionInput')]
96 | public function testItAsksQuestion($expected, $input): void
97 | {
98 | $mock = $this->createMock(BaseCommand::class);
99 | $mock->expects($this->once())
100 | ->method('option')
101 | ->willReturnMap([
102 | ['node-package-manager', null],
103 | ['detect', false],
104 | ]);
105 | $mock->expects($this->once())
106 | ->method('optionalChoice')
107 | ->willReturn($input);
108 |
109 | $this->mock(NodePackageManagerDetector::class, function (MockInterface $mock) {
110 | $mock->shouldReceive('detect')->once()->andReturn(false);
111 | });
112 |
113 | $answer = app(NodePackageManagerQuestion::class)->getAnswer($mock);
114 |
115 | self::assertEquals($expected, $answer);
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/tests/Commands/GenerateQuestions/PhpExtensionsQuestionTest.php:
--------------------------------------------------------------------------------
1 | mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
25 | $mock->shouldReceive('get')
26 | ->once()
27 | ->with(PhpVersion::v8_3)
28 | ->andReturn(['bcmath', 'pdo_mysql']);
29 | });
30 |
31 | $mock = $this->createMock(BaseCommand::class);
32 | $mock->expects($this->once())
33 | ->method('option')
34 | ->with('php-extensions')
35 | ->willReturn('bcmath,pdo_mysql,redis');
36 |
37 | $this->expectException(InvalidOptionValueException::class);
38 | $this->expectExceptionMessage('Extension [redis] is not supported.');
39 |
40 | app(PhpExtensionsQuestion::class)->getAnswer($mock, PhpVersion::v8_3);
41 | }
42 |
43 | public function testItUsesOptionInput(): void
44 | {
45 | $this->mock(SupportedPhpExtensions::class, function (SupportedPhpExtensions&MockInterface $mock) {
46 | $mock->shouldReceive('get')
47 | ->once()
48 | ->with(PhpVersion::v8_3)
49 | ->andReturn(['bcmath', 'pdo_mysql', 'redis']);
50 | });
51 |
52 | $mock = $this->createMock(BaseCommand::class);
53 | $mock->expects($this->once())
54 | ->method('option')
55 | ->with('php-extensions')
56 | ->willReturn('bcmath,pdo_mysql');
57 |
58 | $answer = app(PhpExtensionsQuestion::class)->getAnswer($mock, PhpVersion::v8_3);
59 |
60 | self::assertEquals(['bcmath', 'pdo_mysql'], $answer);
61 | }
62 |
63 | public function testItDetectsExtensions(): void
64 | {
65 | $this->mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
66 | $mock->shouldReceive('get')
67 | ->once()
68 | ->andReturn(['bcmath', 'pdo_mysql', 'redis']);
69 | });
70 |
71 | $this->partialMock(PhpExtensionsDetector::class, function (PhpExtensionsDetector&MockInterface $mock) {
72 | $mock->shouldReceive('supported')
73 | ->once()
74 | ->with(['bcmath', 'pdo_mysql', 'redis'])
75 | ->andReturnSelf();
76 | $mock->shouldReceive('detect')
77 | ->once()
78 | ->withNoArgs()
79 | ->andReturn(['bcmath', 'pdo_mysql']);
80 | });
81 |
82 | $mock = $this->createMock(BaseCommand::class);
83 | $mock->expects($this->exactly(2))
84 | ->method('option')
85 | ->willReturnMap([
86 | ['php-extensions', null],
87 | ['detect', true],
88 | ]);
89 |
90 | $answer = app(PhpExtensionsQuestion::class)->getAnswer($mock, PhpVersion::v8_3);
91 |
92 | self::assertEquals(['bcmath', 'pdo_mysql'], $answer);
93 | }
94 |
95 | public function testItAsksForInput(): void
96 | {
97 | $this->mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
98 | $mock->shouldReceive('get')
99 | ->once()
100 | ->andReturn(['bcmath', 'pdo_mysql', 'redis']);
101 | });
102 |
103 | $this->partialMock(PhpExtensionsDetector::class, function (MockInterface $mock) {
104 | $mock->shouldReceive('supported')
105 | ->once()
106 | ->andReturnSelf();
107 | $mock->shouldReceive('detect')
108 | ->once()
109 | ->andReturn([]);
110 | });
111 |
112 | $mock = $this->createMock(BaseCommand::class);
113 | $mock->expects($this->exactly(2))
114 | ->method('option')
115 | ->willReturnMap([
116 | ['php-extensions', null],
117 | ['detect', null],
118 | ]);
119 | $mock->expects($this->once())
120 | ->method('choice')
121 | ->willReturn(['bcmath', 'pdo_mysql']);
122 |
123 | $answer = app(PhpExtensionsQuestion::class)->getAnswer($mock, PhpVersion::v8_3);
124 |
125 | self::assertEquals(['bcmath', 'pdo_mysql'], $answer);
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/tests/Commands/GenerateQuestions/PhpVersionQuestionTest.php:
--------------------------------------------------------------------------------
1 | createMock(BaseCommand::class);
24 | $mock->expects($this->once())
25 | ->method('option')
26 | ->willReturnMap([
27 | ['php-version', 'invalid-value'],
28 | ]);
29 |
30 | $this->expectException(InvalidOptionValueException::class);
31 |
32 | app(PhpVersionQuestion::class)->getAnswer($mock);
33 | }
34 |
35 | public static function provideOptions(): array
36 | {
37 | return [
38 | '8.3' => ['8.3', '8.3'],
39 | '8.2' => ['8.2', '8.2'],
40 | '8.1' => ['8.1', '8.1'],
41 | ];
42 | }
43 |
44 | #[DataProvider('provideOptions')]
45 | public function testItHandlesOptions($expected, $input): void
46 | {
47 | $mock = $this->createMock(BaseCommand::class);
48 | $mock->expects($this->once())
49 | ->method('option')
50 | ->willReturnMap([
51 | ['php-version', $input],
52 | ]);
53 |
54 | $answer = app(PhpVersionQuestion::class)->getAnswer($mock);
55 |
56 | self::assertEquals($expected, $answer);
57 | }
58 |
59 | public static function provideDetected(): array
60 | {
61 | return [
62 | '8.3' => ['8.3', '8.3'],
63 | '8.2' => ['8.2', '8.2'],
64 | '8.1' => ['8.1', '8.1'],
65 | ];
66 | }
67 |
68 | #[DataProvider('provideDetected')]
69 | public function testItDetectsPackageManagers($expected, $detected): void
70 | {
71 | $mock = $this->createMock(BaseCommand::class);
72 | $mock->expects($this->exactly(2))
73 | ->method('option')
74 | ->willReturnMap([
75 | ['php-version', null],
76 | ['detect', true],
77 | ]);
78 |
79 | $this->mock(PhpVersionDetector::class, function (MockInterface $mock) use ($detected) {
80 | $mock->shouldReceive('detect')->once()->andReturn($detected);
81 | });
82 |
83 | $answer = app(PhpVersionQuestion::class)->getAnswer($mock);
84 |
85 | self::assertEquals($expected, $answer);
86 | }
87 |
88 | public static function provideQuestionInput(): array
89 | {
90 | return [
91 | '8.3' => ['8.3', '8.3'],
92 | '8.2' => ['8.2', '8.2'],
93 | '8.1' => ['8.1', '8.1'],
94 | ];
95 | }
96 |
97 | #[DataProvider('provideQuestionInput')]
98 | public function testItAsksQuestion($expected, $input): void
99 | {
100 | $mock = $this->createMock(BaseCommand::class);
101 | $mock->expects($this->exactly(1))
102 | ->method('option')
103 | ->willReturnMap([
104 | ['php-version', null],
105 | ['detect', false],
106 | ]);
107 | $mock->expects($this->once())
108 | ->method('choice')
109 | ->willReturn($input);
110 |
111 | $this->mock(PhpVersionDetector::class, function (MockInterface $mock) {
112 | $mock->shouldReceive('detect')->once()->andReturn(false);
113 | });
114 |
115 | $answer = app(PhpVersionQuestion::class)->getAnswer($mock);
116 |
117 | self::assertEquals($expected, $answer);
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/tests/Detectors/CiPlatformDetectorTest.php:
--------------------------------------------------------------------------------
1 | detect();
39 |
40 | self::assertEquals('github', $detected);
41 | }
42 |
43 | public function testItDetectsGitLab(): void
44 | {
45 | File::put(
46 | path: base_path('.git/config'),
47 | contents: "[remote \"origin\"]\n\turl = git@gitlab.com:blamebutton/laravel-docker-builder.git",
48 | );
49 |
50 | $detected = app(CiPlatformDetector::class)->detect();
51 |
52 | self::assertEquals('gitlab', $detected);
53 | }
54 |
55 | public function testItReturnsFalseWithNoMatches(): void
56 | {
57 | File::put(
58 | path: base_path('.git/config'),
59 | contents: "[remote \"origin\"]\n\turl = git@bitbucket.com:blamebutton/laravel-docker-builder.git",
60 | );
61 |
62 | $detected = app(CiPlatformDetector::class)->detect();
63 |
64 | self::assertFalse($detected);
65 | }
66 |
67 | public function testItReturnsFalseWithMissingGitConfig(): void
68 | {
69 | $detected = app(CiPlatformDetector::class)->detect();
70 |
71 | self::assertFalse($detected);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/tests/Detectors/FileDetectorTest.php:
--------------------------------------------------------------------------------
1 | mock(FileDetector::class, function (MockInterface $mock) {
25 | $mock->shouldReceive('detect')
26 | ->passthru();
27 | $mock->shouldReceive('getPathMapping')
28 | ->once()
29 | ->andReturn([
30 | base_path('test-file') => 'test',
31 | ]);
32 | });
33 | }
34 |
35 | public function testItDetectsFileWhenPresent(): void
36 | {
37 | touch(base_path('test-file'));
38 |
39 | $detected = app(FileDetector::class)->detect();
40 |
41 | self::assertEquals('test', $detected);
42 | }
43 |
44 | public function testItReturnsFalseWhenFileMissing(): void
45 | {
46 | $detected = app(FileDetector::class)->detect();
47 |
48 | self::assertFalse($detected);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tests/Detectors/NodeBuildToolDetectorTest.php:
--------------------------------------------------------------------------------
1 | ['vite', 'vite.config.js'],
33 | 'vite ts' => ['vite', 'vite.config.ts'],
34 | 'mix' => ['mix', 'webpack.mix.js'],
35 | 'unsupported' => [false, 'unsupported'],
36 | ];
37 | }
38 |
39 | #[DataProvider('providePathMappings')]
40 | public function testItDetectsPaths(string|bool $expected, string $filename): void
41 | {
42 | touch(base_path($filename));
43 |
44 | $detected = app(NodeBuildToolDetector::class)->detect();
45 |
46 | self::assertEquals($expected, $detected);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/tests/Detectors/NodePackageManagerTest.php:
--------------------------------------------------------------------------------
1 | ['npm', 'package-lock.json'],
32 | 'yarn' => ['yarn', 'yarn.lock'],
33 | ];
34 | }
35 |
36 | #[DataProvider('providePathMappings')]
37 | public function testItDetectsPaths(string|bool $expected, string $filename): void
38 | {
39 | touch(base_path($filename));
40 |
41 | $detected = app(NodePackageManagerDetector::class)->detect();
42 |
43 | self::assertEquals($expected, $detected);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/Detectors/PhpExtensionsDetectorTest.php:
--------------------------------------------------------------------------------
1 | app->bind(PhpExtensionsDetector::class, function () {
21 | $supported = [
22 | 'apcu',
23 | 'bcmath',
24 | 'memcached',
25 | 'pdo_mysql',
26 | 'pdo_pgsql',
27 | 'pdo_sqlsrv',
28 | 'redis',
29 | 'sqlsrv',
30 | ];
31 |
32 | return (new PhpExtensionsDetector())
33 | ->supported($supported);
34 | });
35 | }
36 |
37 | public static function provideConfigurations(): array
38 | {
39 | return [
40 | [
41 | ['bcmath'],
42 | [
43 | 'cache.default' => 'array',
44 | 'cache.stores.array.driver' => 'array',
45 | 'cache.stores.redis.driver' => 'redis',
46 | 'database.default' => 'sqlite',
47 | 'database.connections.sqlite.driver' => 'sqlite',
48 | 'broadcasting.default' => 'log',
49 | 'broadcasting.connections.log.driver' => 'log',
50 | 'queue.default' => 'sync',
51 | 'queue.connections.sync.driver' => 'sync',
52 | 'session.driver' => 'array',
53 | ],
54 | ],
55 | [
56 | ['apcu', 'bcmath'],
57 | [
58 | 'cache.default' => 'apc',
59 | 'cache.stores.apc.driver' => 'apc',
60 | 'database.default' => 'sqlite',
61 | 'database.connections.sqlite.driver' => 'sqlite',
62 | 'broadcasting.default' => 'log',
63 | 'broadcasting.connections.log.driver' => 'log',
64 | 'queue.default' => 'sync',
65 | 'queue.connections.sync.driver' => 'sync',
66 | 'session.driver' => 'apc',
67 | ],
68 | ],
69 | [
70 | ['bcmath', 'redis'],
71 | [
72 | 'cache.default' => 'array',
73 | 'cache.stores.array.driver' => 'array',
74 | 'database.default' => 'sqlite',
75 | 'database.connections.sqlite.driver' => 'sqlite',
76 | 'broadcasting.default' => 'log',
77 | 'broadcasting.connections.log.driver' => 'log',
78 | 'queue.default' => 'redis',
79 | 'queue.connections.redis.driver' => 'redis',
80 | 'session.driver' => 'array',
81 | ],
82 | ],
83 | [
84 | ['bcmath', 'redis'],
85 | [
86 | 'cache.default' => 'array',
87 | 'cache.stores.array.driver' => 'array',
88 | 'database.default' => 'sqlite',
89 | 'database.connections.sqlite.driver' => 'sqlite',
90 | 'broadcasting.default' => 'redis',
91 | 'broadcasting.connections.redis.driver' => 'redis',
92 | 'queue.default' => 'sync',
93 | 'queue.connections.redis.driver' => 'sync',
94 | 'session.driver' => 'array',
95 | ],
96 | ],
97 | [
98 | ['apcu', 'bcmath', 'pdo_mysql'],
99 | [
100 | 'cache.default' => 'apc',
101 | 'cache.stores.apc.driver' => 'apc',
102 | 'database.default' => 'mysql',
103 | 'database.connections.mysql.driver' => 'mysql',
104 | 'broadcasting.default' => 'log',
105 | 'broadcasting.connections.log.driver' => 'log',
106 | 'queue.default' => 'sync',
107 | 'queue.connections.sync.driver' => 'sync',
108 | 'session.driver' => 'apc',
109 | ],
110 | ],
111 | [
112 | ['bcmath', 'memcached', 'pdo_sqlsrv', 'redis', 'sqlsrv'],
113 | [
114 | 'cache.default' => 'memcached',
115 | 'cache.stores.memcached.driver' => 'memcached',
116 | 'cache.stores.redis.driver' => 'redis',
117 | 'database.default' => 'sqlsrv',
118 | 'database.connections.sqlsrv.driver' => 'sqlsrv',
119 | 'broadcasting.default' => 'log',
120 | 'broadcasting.connections.log.driver' => 'log',
121 | 'queue.default' => 'sync',
122 | 'queue.connections.sync.driver' => 'sync',
123 | 'session.driver' => 'redis',
124 | ],
125 | ],
126 | ];
127 | }
128 |
129 | #[DataProvider('provideConfigurations')]
130 | public function testItDetectsExtensionsWithoutDuplicates(array $expected, array $config): void
131 | {
132 | config()->set($config);
133 |
134 | $detected = app(PhpExtensionsDetector::class)->detect();
135 |
136 | self::assertEquals($expected, $detected);
137 | }
138 |
139 | public function testItReturnsDefaultExtensions(): void
140 | {
141 | $detected = app(PhpExtensionsDetector::class)->getDefaultExtensions();
142 |
143 | self::assertEquals(['bcmath'], $detected);
144 | }
145 |
146 | public static function provideCacheConfigurations(): array
147 | {
148 | return [
149 | 'apc' => [
150 | ['apcu'],
151 | ['cache.default' => 'apc', 'cache.stores.apc.driver' => 'apc'],
152 | ],
153 | 'memcached' => [
154 | ['memcached'],
155 | ['cache.default' => 'memcached', 'cache.stores.memcached.driver' => 'memcached'],
156 | ],
157 | 'redis' => [
158 | ['redis'],
159 | ['cache.default' => 'redis', 'cache.stores.redis.driver' => 'redis'],
160 | ],
161 | 'array' => [
162 | [],
163 | ['cache.default' => 'array', 'cache.stores.array.driver' => 'array'],
164 | ],
165 | ];
166 | }
167 |
168 | #[DataProvider('provideCacheConfigurations')]
169 | public function testItDetectsCacheExtensions(array $expected, array $config): void
170 | {
171 | config()->set($config);
172 |
173 | $detected = app(PhpExtensionsDetector::class)->getCacheExtensions();
174 |
175 | self::assertEquals($expected, $detected);
176 | }
177 |
178 | public static function provideDatabaseConfigurations(): array
179 | {
180 | return [
181 | 'mysql' => [
182 | ['pdo_mysql'],
183 | ['database.default' => 'mysql', 'database.connections.mysql.driver' => 'mysql'],
184 | ],
185 | 'pgsql' => [
186 | ['pdo_pgsql'],
187 | ['database.default' => 'pgsql', 'database.connections.pgsql.driver' => 'pgsql'],
188 | ],
189 | 'sqlsrv' => [
190 | ['pdo_sqlsrv', 'sqlsrv'],
191 | ['database.default' => 'sqlsrv', 'database.connections.sqlsrv.driver' => 'sqlsrv'],
192 | ],
193 | 'sqlite' => [
194 | [],
195 | ['database.default' => 'sqlite', 'database.connections.sqlite.driver' => 'sqlite'],
196 | ],
197 | ];
198 | }
199 |
200 | #[DataProvider('provideDatabaseConfigurations')]
201 | public function testItDetectsDatabaseExtensions(array $expected, array $config): void
202 | {
203 | config()->set($config);
204 |
205 | $detected = app(PhpExtensionsDetector::class)->getDatabaseExtensions();
206 |
207 | self::assertEquals($expected, $detected);
208 | }
209 |
210 | public static function provideBroadcastingConfigurations(): array
211 | {
212 | return [
213 | 'redis' => [
214 | ['redis'],
215 | ['broadcasting.default' => 'redis', 'broadcasting.connections.redis.driver' => 'redis'],
216 | ],
217 | 'log' => [
218 | [],
219 | ['broadcasting.default' => 'log', 'database.connections.log.driver' => 'log'],
220 | ],
221 | ];
222 | }
223 |
224 | #[DataProvider('provideBroadcastingConfigurations')]
225 | public function testItDetectsBroadcastingExtensions(array $expected, array $config): void
226 | {
227 | config()->set($config);
228 |
229 | $detected = app(PhpExtensionsDetector::class)->getBroadcastingExtensions();
230 |
231 | self::assertEquals($expected, $detected);
232 | }
233 |
234 | public static function provideQueueConfigurations(): array
235 | {
236 | return [
237 | 'redis' => [
238 | ['redis'],
239 | ['queue.default' => 'redis', 'queue.connections.redis.driver' => 'redis'],
240 | ],
241 | 'sync' => [
242 | [],
243 | ['queue.default' => 'sync', 'queue.connections.sync.driver' => 'sync'],
244 | ],
245 | ];
246 | }
247 |
248 | #[DataProvider('provideQueueConfigurations')]
249 | public function testItDetectsQueueExtensions(array $expected, array $config): void
250 | {
251 | config()->set($config);
252 |
253 | $detected = app(PhpExtensionsDetector::class)->getQueueExtensions();
254 |
255 | self::assertEquals($expected, $detected);
256 | }
257 |
258 | public static function provideSessionConfigurations(): array
259 | {
260 | return [
261 | 'apc' => [
262 | ['apcu'],
263 | ['session.driver' => 'apc', 'cache.stores.apc.driver' => 'apc'],
264 | ],
265 | 'memcached' => [
266 | ['memcached'],
267 | ['session.driver' => 'memcached', 'cache.stores.memcached.driver' => 'memcached'],
268 | ],
269 | 'redis' => [
270 | ['redis'],
271 | ['session.driver' => 'redis', 'cache.stores.redis.driver' => 'redis'],
272 | ],
273 | 'array' => [
274 | [],
275 | ['session.driver' => 'array', 'cache.stores.array.driver' => 'array'],
276 | ],
277 | ];
278 | }
279 |
280 | #[DataProvider('provideSessionConfigurations')]
281 | public function testItDetectsSessionExtensions(array $expected, array $config): void
282 | {
283 | config()->set($config);
284 |
285 | $detected = app(PhpExtensionsDetector::class)->getSessionExtensions();
286 |
287 | self::assertEquals($expected, $detected);
288 | }
289 | }
290 |
--------------------------------------------------------------------------------
/tests/Detectors/PhpVersionDetectorTest.php:
--------------------------------------------------------------------------------
1 | partialMock(PhpVersionDetector::class, function (MockInterface $mock) {
22 | $mock->shouldReceive('getComposerFileContents')
23 | ->once()
24 | ->andReturn(false);
25 | });
26 |
27 | $detected = app(PhpVersionDetector::class)->detect();
28 |
29 | self::assertFalse($detected);
30 | }
31 |
32 | public function testItReturnsFalseWhenNoPhpVersionWasFound(): void
33 | {
34 | $this->partialMock(PhpVersionDetector::class, function (MockInterface $mock) {
35 | $mock->shouldReceive('getComposerFileContents')
36 | ->once()
37 | ->andReturn('{ "require": {} }');
38 | });
39 |
40 | $detected = app(PhpVersionDetector::class)->detect();
41 |
42 | self::assertFalse($detected);
43 | }
44 |
45 | public static function provideVersions(): array
46 | {
47 | return [
48 | ['8.3', '8.3.*'],
49 | ['8.3', '~8.3'],
50 | ['8.3', '^8.3'],
51 | ['8.2', '^8.2'],
52 | ['8.2', '>=8.2'],
53 | ['8.1', '~8.1'],
54 | ['8.1', '8.1.*'],
55 | ];
56 | }
57 |
58 | #[DataProvider('provideVersions')]
59 | public function testItParsesJsonVersion($expected, string $version): void
60 | {
61 | $this->partialMock(PhpVersionDetector::class, function (MockInterface $mock) use ($version) {
62 | $mock->shouldReceive('getComposerFileContents')
63 | ->once()
64 | ->andReturn(sprintf('{ "require": { "php": "%s" } }', $version));
65 | });
66 |
67 | $this->partialMock(VersionParser::class, function (MockInterface $mock) use ($version) {
68 | $mock->shouldReceive('parseConstraints')
69 | ->with($version)
70 | ->once()
71 | ->passthru();
72 | });
73 |
74 | $detected = app(PhpVersionDetector::class)->detect();
75 |
76 | self::assertEquals($expected, $detected);
77 | }
78 |
79 | public function testItGetsComposerFileContents(): void
80 | {
81 | $contents = app(PhpVersionDetector::class)->getComposerFileContents();
82 |
83 | self::assertJson($contents);
84 |
85 | $json = json_decode($contents);
86 |
87 | self::assertEquals('laravel/laravel', data_get($json, 'name'));
88 | }
89 |
90 | public function testItReturnsFalseOnMissingComposerFile(): void
91 | {
92 | $this->app->setBasePath('non-existent');
93 |
94 | $detected = app(PhpVersionDetector::class)->getComposerFileContents();
95 |
96 | self::assertFalse($detected);
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/tests/DockerServiceProviderTest.php:
--------------------------------------------------------------------------------
1 | partialMock(SupportedPhpExtensions::class, function (MockInterface $mock) {
27 | $mock->shouldReceive('get')->once()->andReturn([]);
28 | });
29 |
30 | $commands = app(Kernel::class)->all();
31 |
32 | self::assertArrayHasKey('docker:build', $commands);
33 | self::assertArrayHasKey('docker:ci', $commands);
34 | self::assertArrayHasKey('docker:generate', $commands);
35 | self::assertArrayHasKey('docker:push', $commands);
36 | }
37 |
38 | public function testItPublishesConfig(): void
39 | {
40 | self::assertArrayHasKey(DockerServiceProvider::class, ServiceProvider::$publishes);
41 | self::assertEquals([
42 | package_path('config/docker-builder.php') => base_path('config/docker-builder.php'),
43 | ], ServiceProvider::$publishes[DockerServiceProvider::class]);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/HelpersTest.php:
--------------------------------------------------------------------------------
1 | mock(SupportedPhpExtensions::class, function (MockInterface $mock) use ($version) {
32 | $mock->shouldAllowMockingProtectedMethods();
33 | $mock->shouldReceive('get')
34 | ->with($version)
35 | ->once()
36 | ->passthru();
37 | $mock->shouldReceive('fetch')
38 | ->withNoArgs()
39 | ->once()
40 | ->andReturn([
41 | 'apcu 8.2 8.3',
42 | 'pdo_mysql 8.3',
43 | 'pdo_pgsql 8.1 8.2',
44 | 'memcached 8.1',
45 | 'redis 8.2',
46 | ]);
47 | });
48 |
49 | $supported = app(SupportedPhpExtensions::class)->get($version);
50 |
51 | self::assertEquals($expected, $supported);
52 | }
53 |
54 | public function testItReturnsEmptyArrayOnError(): void
55 | {
56 | $this->mock(SupportedPhpExtensions::class, function (SupportedPhpExtensions&MockInterface $mock) {
57 | $mock->shouldAllowMockingProtectedMethods();
58 | $mock->shouldReceive('get')->once()->with(PhpVersion::v8_3)->passthru();
59 | $mock->shouldReceive('fetch')->once()->withNoArgs()->andReturn(false);
60 | });
61 |
62 | $supported = app(SupportedPhpExtensions::class)->get(PhpVersion::v8_3);
63 |
64 | self::assertEquals([], $supported);
65 | }
66 |
67 | public function testItCachesResponse(): void
68 | {
69 | $this->mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
70 | $mock->shouldAllowMockingProtectedMethods();
71 | $mock->shouldReceive('get')->twice()->passthru();
72 | $mock->shouldReceive('fetch')->once()->andReturn();
73 | });
74 |
75 | app(SupportedPhpExtensions::class)->get();
76 | app(SupportedPhpExtensions::class)->get();
77 | }
78 |
79 | public function testItReturnsFalseOnError(): void
80 | {
81 | Http::fake([
82 | 'github.com/*' => Http::response("bcmath\nmemcached\n", 500),
83 | ]);
84 |
85 | $this->mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
86 | $mock->shouldAllowMockingProtectedMethods();
87 | $mock->shouldReceive('fetch')->once()->passthru();
88 | });
89 |
90 | $response = app(SupportedPhpExtensions::class)->fetch();
91 |
92 | self::assertFalse($response);
93 | }
94 |
95 | public function testItReturnsExtensions(): void
96 | {
97 | Http::fake([
98 | 'github.com/*' => Http::response("bcmath\nmemcached\n"),
99 | ]);
100 |
101 | $this->mock(SupportedPhpExtensions::class, function (MockInterface $mock) {
102 | $mock->shouldAllowMockingProtectedMethods();
103 | $mock->shouldReceive('fetch')->once()->passthru();
104 | });
105 |
106 | $response = app(SupportedPhpExtensions::class)->fetch();
107 |
108 | self::assertEquals(['bcmath', 'memcached'], $response);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/tests/Objects/ConfigurationTest.php:
--------------------------------------------------------------------------------
1 | getPhpVersion());
40 | self::assertEquals(['bcmath'], $config->getPhpExtensions());
41 | self::assertTrue($config->isArtisanOptimize());
42 | self::assertTrue($config->isAlpine());
43 | self::assertEquals('npm', $config->getNodePackageManager());
44 | self::assertEquals('vite', $config->getNodeBuildTool());
45 | }
46 |
47 | #[DataProvider('provideCommandOptions')]
48 | public function testItGeneratesCorrectCommand(string $expected, Configuration $config): void
49 | {
50 | $output = $config->getCommand();
51 |
52 | self::assertEquals($expected, implode(' ', $output));
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/tests/Pest.php:
--------------------------------------------------------------------------------
1 | in(__DIR__);
6 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | >
17 | */
18 | protected function getPackageProviders($app): array
19 | {
20 | return [
21 | DockerServiceProvider::class,
22 | ];
23 | }
24 |
25 | public function getEnvironmentSetUp($app): void
26 | {
27 | config()->set('database.default', 'testing');
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/Traits/InteractsWithTwigTest.php:
--------------------------------------------------------------------------------
1 | getObjectForTrait(InteractsWithTwig::class);
23 |
24 | $twig = $class->twig();
25 | self::assertInstanceOf(Environment::class, $twig);
26 | $loader = $twig->getLoader();
27 | self::assertInstanceOf(FilesystemLoader::class, $loader);
28 | self::assertEquals([package_path('resources/templates')], $loader->getPaths());
29 | }
30 |
31 | public function testItCachesTwigInstances(): void
32 | {
33 | /** @var InteractsWithTwig $class */
34 | $class = $this->getObjectForTrait(InteractsWithTwig::class);
35 |
36 | $twig = $class->twig();
37 |
38 | self::assertSame($class->twig(), $twig);
39 | }
40 |
41 | public function testItThrowsErrorOnMissingTemplates(): void
42 | {
43 | /** @var InteractsWithTwig $class */
44 | $class = $this->getObjectForTrait(InteractsWithTwig::class);
45 |
46 | $this->expectException(LoaderError::class);
47 |
48 | $class->render('invalid-filename', []);
49 | }
50 |
51 | public function testItRendersNginxTemplate(): void
52 | {
53 | /** @var InteractsWithTwig $class */
54 | $class = $this->getObjectForTrait(InteractsWithTwig::class);
55 |
56 | $output = $class->render('nginx.dockerfile.twig', []);
57 |
58 | self::assertStringContainsString("FROM nginx:1\n", $output);
59 | }
60 |
61 | public function testItRendersPhpTemplate(): void
62 | {
63 | /** @var InteractsWithTwig $class */
64 | $class = $this->getObjectForTrait(InteractsWithTwig::class);
65 |
66 | $output = $class->render('php.dockerfile.twig', [
67 | 'php_version' => '8.2',
68 | ]);
69 |
70 | self::assertStringContainsString("FROM php:8.2-fpm\n", $output);
71 | }
72 | }
73 |
--------------------------------------------------------------------------------