├── .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 | ![Banner](https://banners.beyondco.de/Laravel%20Docker%20Builder.png?theme=light&packageManager=composer+require&packageName=blamebutton%2Flaravel-docker-builder&pattern=architect&style=style_1&description=Create+Dockerfiles+and+Kubernetes+manifests+for+your+application&md=1&showWatermark=1&fontSize=100px&images=https%3A%2F%2Flaravel.com%2Fimg%2Flogomark.min.svg&widths=auto) 2 | 3 | [![Packagist Version](https://img.shields.io/packagist/v/blamebutton/laravel-docker-builder)](https://packagist.org/packages/blamebutton/laravel-docker-builder) 4 | [![Packagist Downloads](https://img.shields.io/packagist/dm/blamebutton/laravel-docker-builder)](https://packagist.org/packages/blamebutton/laravel-docker-builder) 5 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/blamebutton/laravel-docker-builder/phpunit.yml) 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 | --------------------------------------------------------------------------------