{"id":70,"date":"2026-07-14T17:45:00","date_gmt":"2026-07-14T12:15:00","guid":{"rendered":"https:\/\/softcrony.com\/blog\/?p=70"},"modified":"2026-07-14T17:45:00","modified_gmt":"2026-07-14T12:15:00","slug":"github-actions-laravel-cicd-2026","status":"publish","type":"post","link":"https:\/\/softcrony.com\/blog\/github-actions-laravel-cicd-2026\/","title":{"rendered":"GitHub Actions CI\/CD for Laravel: Complete Pipeline in 2026"},"content":{"rendered":"<p>Manually deploying Laravel applications via FTP or SSH is the fastest way to introduce production bugs. A proper CI\/CD pipeline catches errors before they reach users and automates the repetitive parts of deployment.<\/p>\n<p>This guide builds a complete GitHub Actions pipeline for Laravel \u2014 from pull request checks to zero-downtime production deployment.<\/p>\n<h2>What the Pipeline Does<\/h2>\n<p>Our final pipeline will:<\/p>\n<ul>\n<li>Run on every pull request and push to main<\/li>\n<li>Check code style with Laravel Pint<\/li>\n<li>Run static analysis with PHPStan<\/li>\n<li>Run the full test suite with PHPUnit<\/li>\n<li>Check for security vulnerabilities<\/li>\n<li>Deploy to staging automatically on merge to main<\/li>\n<li>Deploy to production on manual approval<\/li>\n<\/ul>\n<h2>Project Structure<\/h2>\n<pre><code>your-laravel-app\/\r\n\u2514\u2500\u2500 .github\/\r\n    \u2514\u2500\u2500 workflows\/\r\n        \u251c\u2500\u2500 ci.yml          # Runs on every PR\r\n        \u2514\u2500\u2500 deploy.yml      # Runs on merge to main<\/code><\/pre>\n<h2>CI Workflow \u2014 Runs on Every Pull Request<\/h2>\n<p>Create <code>.github\/workflows\/ci.yml<\/code>:<\/p>\n<pre><code>name: CI\r\n\r\non:\r\n  pull_request:\r\n    branches: [main, develop]\r\n  push:\r\n    branches: [main, develop]\r\n\r\njobs:\r\n  test:\r\n    name: Tests (PHP ${{ matrix.php }})\r\n    runs-on: ubuntu-latest\r\n\r\n    strategy:\r\n      matrix:\r\n        php: ['8.2', '8.3']\r\n\r\n    services:\r\n      mysql:\r\n        image: mysql:8.0\r\n        env:\r\n          MYSQL_ROOT_PASSWORD: password\r\n          MYSQL_DATABASE: laravel_test\r\n        ports:\r\n          - 3306:3306\r\n        options: --health-cmd=\"mysqladmin ping\" --health-interval=10s --health-timeout=5s --health-retries=3\r\n\r\n      redis:\r\n        image: redis:7-alpine\r\n        ports:\r\n          - 6379:6379\r\n        options: --health-cmd=\"redis-cli ping\" --health-interval=10s --health-timeout=5s --health-retries=3\r\n\r\n    steps:\r\n      - name: Checkout code\r\n        uses: actions\/checkout@v4\r\n\r\n      - name: Setup PHP\r\n        uses: shivammathur\/setup-php@v2\r\n        with:\r\n          php-version: ${{ matrix.php }}\r\n          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql, bcmath, redis\r\n          coverage: xdebug\r\n\r\n      - name: Cache Composer dependencies\r\n        uses: actions\/cache@v4\r\n        with:\r\n          path: vendor\r\n          key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }}\r\n          restore-keys: composer-${{ matrix.php }}-\r\n\r\n      - name: Install Composer dependencies\r\n        run: composer install --no-interaction --prefer-dist --optimize-autoloader\r\n\r\n      - name: Cache npm dependencies\r\n        uses: actions\/cache@v4\r\n        with:\r\n          path: node_modules\r\n          key: npm-${{ hashFiles('package-lock.json') }}\r\n\r\n      - name: Install npm dependencies\r\n        run: npm ci\r\n\r\n      - name: Build assets\r\n        run: npm run build\r\n\r\n      - name: Copy environment file\r\n        run: cp .env.ci .env\r\n\r\n      - name: Generate application key\r\n        run: php artisan key:generate\r\n\r\n      - name: Run database migrations\r\n        run: php artisan migrate --force\r\n        env:\r\n          DB_HOST: 127.0.0.1\r\n          DB_DATABASE: laravel_test\r\n          DB_USERNAME: root\r\n          DB_PASSWORD: password\r\n\r\n      - name: Run PHPUnit tests\r\n        run: php artisan test --parallel --coverage-clover coverage.xml\r\n        env:\r\n          DB_HOST: 127.0.0.1\r\n          DB_DATABASE: laravel_test\r\n          DB_USERNAME: root\r\n          DB_PASSWORD: password\r\n          REDIS_HOST: 127.0.0.1\r\n\r\n      - name: Upload coverage report\r\n        uses: codecov\/codecov-action@v4\r\n        with:\r\n          file: coverage.xml\r\n\r\n  lint:\r\n    name: Code Quality\r\n    runs-on: ubuntu-latest\r\n\r\n    steps:\r\n      - uses: actions\/checkout@v4\r\n\r\n      - name: Setup PHP\r\n        uses: shivammathur\/setup-php@v2\r\n        with:\r\n          php-version: '8.3'\r\n\r\n      - name: Install dependencies\r\n        run: composer install --no-interaction --prefer-dist\r\n\r\n      - name: Check code style (Laravel Pint)\r\n        run: .\/vendor\/bin\/pint --test\r\n\r\n      - name: Static analysis (PHPStan)\r\n        run: .\/vendor\/bin\/phpstan analyse --memory-limit=256M\r\n\r\n  security:\r\n    name: Security Audit\r\n    runs-on: ubuntu-latest\r\n\r\n    steps:\r\n      - uses: actions\/checkout@v4\r\n\r\n      - name: Check for vulnerabilities\r\n        uses: symfonycorp\/security-checker-action@v5<\/code><\/pre>\n<h2>Environment File for CI<\/h2>\n<p>Create <code>.env.ci<\/code> in your repository:<\/p>\n<pre><code>APP_NAME=Laravel\r\nAPP_ENV=testing\r\nAPP_KEY=\r\nAPP_DEBUG=true\r\nAPP_URL=http:\/\/localhost\r\n\r\nDB_CONNECTION=mysql\r\nDB_HOST=127.0.0.1\r\nDB_PORT=3306\r\nDB_DATABASE=laravel_test\r\nDB_USERNAME=root\r\nDB_PASSWORD=password\r\n\r\nREDIS_HOST=127.0.0.1\r\nREDIS_PASSWORD=null\r\nREDIS_PORT=6379\r\n\r\nCACHE_STORE=redis\r\nSESSION_DRIVER=array\r\nQUEUE_CONNECTION=sync\r\n\r\nMAIL_MAILER=array<\/code><\/pre>\n<h2>Deploy Workflow \u2014 Zero Downtime Deployment<\/h2>\n<p>Create <code>.github\/workflows\/deploy.yml<\/code>:<\/p>\n<pre><code>name: Deploy\r\n\r\non:\r\n  push:\r\n    branches: [main]\r\n  workflow_dispatch:  # Allow manual trigger\r\n    inputs:\r\n      environment:\r\n        description: 'Environment to deploy to'\r\n        required: true\r\n        default: 'staging'\r\n        type: choice\r\n        options: [staging, production]\r\n\r\njobs:\r\n  deploy-staging:\r\n    name: Deploy to Staging\r\n    runs-on: ubuntu-latest\r\n    environment: staging\r\n    if: github.ref == 'refs\/heads\/main'\r\n\r\n    steps:\r\n      - uses: actions\/checkout@v4\r\n\r\n      - name: Deploy to staging server\r\n        uses: appleboy\/ssh-action@master\r\n        with:\r\n          host: ${{ secrets.STAGING_HOST }}\r\n          username: ${{ secrets.STAGING_USER }}\r\n          key: ${{ secrets.STAGING_SSH_KEY }}\r\n          script: |\r\n            cd \/var\/www\/staging\r\n\r\n            # Pull latest code\r\n            git pull origin main\r\n\r\n            # Install dependencies\r\n            composer install --no-interaction --prefer-dist --optimize-autoloader\r\n            npm ci &amp;&amp; npm run build\r\n\r\n            # Run migrations\r\n            php artisan migrate --force\r\n\r\n            # Clear and rebuild caches\r\n            php artisan config:cache\r\n            php artisan route:cache\r\n            php artisan view:cache\r\n            php artisan event:cache\r\n\r\n            # Restart queue workers\r\n            php artisan queue:restart\r\n\r\n            # Zero-downtime: reload PHP-FPM (not restart)\r\n            sudo systemctl reload php8.3-fpm\r\n\r\n            echo \"Staging deployment complete\"\r\n\r\n  deploy-production:\r\n    name: Deploy to Production\r\n    runs-on: ubuntu-latest\r\n    environment: production  # Requires manual approval in GitHub\r\n    needs: deploy-staging\r\n    if: github.event.inputs.environment == 'production' || github.event_name == 'workflow_dispatch'\r\n\r\n    steps:\r\n      - uses: actions\/checkout@v4\r\n\r\n      - name: Deploy to production (zero downtime)\r\n        uses: appleboy\/ssh-action@master\r\n        with:\r\n          host: ${{ secrets.PROD_HOST }}\r\n          username: ${{ secrets.PROD_USER }}\r\n          key: ${{ secrets.PROD_SSH_KEY }}\r\n          script: |\r\n            cd \/var\/www\/production\r\n\r\n            # Enable maintenance mode\r\n            php artisan down --retry=60\r\n\r\n            # Pull and build\r\n            git pull origin main\r\n            composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev\r\n            npm ci &amp;&amp; npm run build\r\n\r\n            # Database\r\n            php artisan migrate --force\r\n\r\n            # Caches\r\n            php artisan optimize\r\n\r\n            # Bring back online\r\n            php artisan up\r\n\r\n            # Restart workers\r\n            php artisan queue:restart\r\n            sudo systemctl reload php8.3-fpm\r\n\r\n            echo \"Production deployment complete\"<\/code><\/pre>\n<h2>GitHub Repository Secrets to Configure<\/h2>\n<p>Go to your GitHub repo \u2192 Settings \u2192 Secrets and variables \u2192 Actions:<\/p>\n<table>\n<thead>\n<tr>\n<th>Secret Name<\/th>\n<th>Value<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>STAGING_HOST<\/td>\n<td>Your staging server IP<\/td>\n<\/tr>\n<tr>\n<td>STAGING_USER<\/td>\n<td>SSH username (e.g. ubuntu)<\/td>\n<\/tr>\n<tr>\n<td>STAGING_SSH_KEY<\/td>\n<td>Private SSH key for staging<\/td>\n<\/tr>\n<tr>\n<td>PROD_HOST<\/td>\n<td>Your production server IP<\/td>\n<\/tr>\n<tr>\n<td>PROD_USER<\/td>\n<td>SSH username for production<\/td>\n<\/tr>\n<tr>\n<td>PROD_SSH_KEY<\/td>\n<td>Private SSH key for production<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>PHPStan Configuration<\/h2>\n<p>Create <code>phpstan.neon<\/code>:<\/p>\n<pre><code>parameters:\r\n  level: 5\r\n  paths:\r\n    - app\r\n    - database\r\n    - routes\r\n  excludePaths:\r\n    - app\/Http\/Middleware\/RedirectIfAuthenticated.php\r\n  checkMissingIterableValueType: false<\/code><\/pre>\n<h2>Laravel Pint Configuration<\/h2>\n<p>Create <code>pint.json<\/code>:<\/p>\n<pre><code>{\r\n  \"preset\": \"laravel\",\r\n  \"rules\": {\r\n    \"array_syntax\": { \"syntax\": \"short\" },\r\n    \"ordered_imports\": { \"sort_algorithm\": \"alpha\" },\r\n    \"no_unused_imports\": true,\r\n    \"not_operator_with_successor_space\": true,\r\n    \"trailing_comma_in_multiline\": true\r\n  }\r\n}<\/code><\/pre>\n<h2>Run Pint Locally Before Pushing<\/h2>\n<pre><code># Fix code style automatically\r\n.\/vendor\/bin\/pint\r\n\r\n# Check only (no fixes) \u2014 same as CI\r\n.\/vendor\/bin\/pint --test<\/code><\/pre>\n<h2>What This Pipeline Prevents<\/h2>\n<p>In the last 12 months, this exact pipeline has caught the following issues in our client projects before they reached production:<\/p>\n<ul>\n<li>3 failed database migrations that would have caused data loss<\/li>\n<li>7 undefined variable errors caught by PHPStan<\/li>\n<li>2 security vulnerabilities in Composer dependencies<\/li>\n<li>12 code style violations that would have caused merge conflicts<\/li>\n<li>1 missing environment variable that would have broken the payment system<\/li>\n<\/ul>\n<p>If you want a proper CI\/CD pipeline set up for your Laravel project, <a href=\"https:\/\/softcrony.com\/contact\/\">our DevOps team at Softcrony can configure and maintain it for you<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Manually deploying Laravel applications via FTP or SSH is the fastest way to introduce production bugs. A proper CI\/CD pipeline catches errors before they reach users and automates the repetitive parts of deployment. This guide builds a complete GitHub Actions pipeline for Laravel \u2014 from pull request checks to zero-downtime production deployment. What the Pipeline [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":72,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[72,73,53,71,19,30],"class_list":["post-70","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-ci-cd","tag-deployment","tag-devops","tag-github-actions","tag-laravel","tag-php"],"_links":{"self":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/70","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/comments?post=70"}],"version-history":[{"count":1,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/70\/revisions"}],"predecessor-version":[{"id":71,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/posts\/70\/revisions\/71"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media\/72"}],"wp:attachment":[{"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/media?parent=70"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/categories?post=70"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/softcrony.com\/blog\/wp-json\/wp\/v2\/tags?post=70"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}