
September 09, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
GitHub Actions Reusable Workflows and Matrix Builds solve the same problem every growing repo hits: your test job YAML grows until three projects need the same steps. You copy a block, tweak PHP versions, and six months later the files drift apart. Reusable workflows let one repository own the pipeline logic. Matrix builds fan that logic across PHP 8.3, 8.4, and 8.5—or across Ubuntu and macOS—without tripling your file size. If you already run GitHub Actions for Laravel testing and deploy, this is the next layer that keeps CI maintainable as teams and packages multiply.
workflow_call) with a strategy.matrix block so one shared pipeline runs many times with different inputs—PHP versions, OS images, or service containers—without duplicating YAML across repositories.What are GitHub Actions reusable workflows and how do they differ from composite actions?
A reusable workflow is a normal workflow file that other workflows invoke with uses:. It lives in .github/workflows/ and declares on: workflow_call. The caller passes inputs and secrets. The callee runs as a child workflow with its own job graph.
Composite actions bundle steps inside a single job. Reusable workflows can span multiple jobs, call other reusable workflows, and enforce organisation-wide standards. On production Laravel apps I maintain, reusable workflows fit org-wide PHP test templates. Composite actions fit smaller step groups like "install Composer deps with cache".
The mental model matches Terraform modules for reusable infrastructure. Your org repo holds the canonical test pipeline. Each app repo calls it with project-specific inputs. When PHPUnit or Pest steps change, you update one file—not twelve forks of the same job.
| Feature | Reusable Workflow | Composite Action | Duplicated YAML |
|---|---|---|---|
| Multiple jobs | Yes | No | Yes |
| Cross-repo sharing | Yes | Yes | Manual sync |
| Matrix fan-out | At caller or callee | Inside one job only | Per repo |
| Secrets scope | Explicit pass or inherit | Job-level only | Per repo |
| Best for | Full CI templates | Step bundles | One-off projects |
GitHub documents reusable workflows in their official reusing workflows guide. Read that alongside this article when you wire your first callable file.
How do you create a reusable workflow in GitHub Actions?
Start in a dedicated repo—often org/.github or a ci-templates repo. Create .github/workflows/php-test.yml with a workflow_call trigger and typed inputs.
Define the reusable workflow
# .github/workflows/php-test.yml (in org/ci-templates repo)
name: PHP Test Template
on:
workflow_call:
inputs:
php-version:
required: true
type: string
laravel-version:
required: false
type: string
default: "12"
secrets:
COMPOSER_AUTH:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ inputs.php-version }}
extensions: mbstring, pdo_mysql, redis
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: vendor/bin/pest --parallel
Inputs become available as ${{ inputs.php-version }}. Secrets do not flow automatically unless the caller sets secrets: inherit: true or maps them explicitly. That default protects tokens from leaking into workflows that should not see them.
Call it from an application repository
# .github/workflows/ci.yml (in your Laravel app repo)
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
php-tests:
uses: my-org/ci-templates/.github/workflows/php-test.yml@main
with:
php-version: "8.4"
laravel-version: "12"
secrets: inherit
Pin the ref to a tag or SHA—not a floating branch—in production. I tag template repos v1.4.0 and bump callers deliberately. A silent break on @main at 2 AM is not a drill you want.
For Laravel 13 you need PHP 8.3 minimum. Laravel 12 runs on PHP 8.2 or higher. Your reusable workflow inputs should document those floors so callers do not pass invalid pairs. See our Laravel API build guide for project layout assumptions this pipeline expects.
How does the matrix strategy work in GitHub Actions?
A matrix tells GitHub to run the same job multiple times with different variable sets. You declare strategy.matrix keys. GitHub creates one job per combination unless you add exclude or include rules.
A typical Laravel matrix might cover PHP 8.3, 8.4, and 8.5 on Ubuntu. Add macOS only when you ship native extensions or CLI tools that behave differently. Each extra cell costs runner minutes. Budget accordingly—especially on private repos.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
max-parallel: 4
matrix:
php: ["8.3", "8.4", "8.5"]
os: [ubuntu-latest]
exclude:
- php: "8.5"
os: ubuntu-latest # example: opt out until deps support 8.5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- run: composer install
- run: vendor/bin/pest
Set fail-fast: false on library packages so one PHP version failure still shows results for the rest. Use true on app repos during active development when you want fast feedback. The GitHub matrix jobs documentation covers include for adding odd combinations without exploding the default grid.
Service containers pair well with matrices. A MySQL 8.4 LTS cell and a MySQL 9.7 cell catch driver edge cases before production. Validate generated workflow YAML with a JSON or YAML formatter tool when debugging matrix syntax errors—GitHub's error messages often point at line numbers in expanded configs.
How do you combine reusable workflows with matrix builds?
You have two valid patterns. Both appear in real orgs. Pick based on who owns the matrix definition.
Pattern A: matrix on the caller, reusable workflow per cell
The caller job defines the matrix and passes one value set per invocation. Each matrix cell becomes a separate reusable workflow run.
jobs:
test:
strategy:
matrix:
php: ["8.3", "8.4", "8.5"]
uses: my-org/ci-templates/.github/workflows/php-test.yml@v2
with:
php-version: ${{ matrix.php }}
secrets: inherit
This keeps version lists visible in each app repo. Product teams choose their supported PHP floor. The shared template stays version-agnostic.
Pattern B: matrix inside the reusable workflow
The reusable workflow owns the full grid. Callers invoke one line. Upgrading PHP 8.5 support happens centrally.
# Inside reusable workflow
jobs:
test:
strategy:
matrix:
php: ["8.3", "8.4", "8.5"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
I use Pattern A for client repos with different Laravel versions. Pattern B fits internal packages where the platform team controls compatibility policy entirely. The approach mirrors Ansible roles and Galaxy reusable automation: parameters at the edge, logic in the middle.
Nesting reusable workflows is supported up to depth limits GitHub documents. A top-level CI file can call a reusable test workflow and a reusable deploy workflow sequentially. Deploy steps should not sit inside the test matrix unless you enjoy paying for redundant SSH keys.
What production setup works for Laravel and PHP projects?
On booking platforms like Adventure Third Pole Trek and sister legal-tech sites I deploy with GitLab CI and Deployer 7. Many teams migrating to GitHub want the same test grid without rewriting deploy logic. Reusable workflows cover the test half cleanly. Pair them with OIDC deploy workflows without long-lived keys for the release half.
A production-grade caller file for Laravel 12 might look like this:
- Trigger on push, pull request, and weekly cron for dependency drift.
- Call a reusable lint job—Pint or PHP CS Fixer—once on PHP 8.4.
- Matrix test across PHP 8.3, 8.4, and 8.5 with MySQL 8.4 service container.
- Build frontend assets with Node.js 26 LTS in a separate job—not inside every matrix cell.
- Call a reusable deploy workflow only from
mainafter tests pass.
jobs:
lint:
uses: my-org/ci-templates/.github/workflows/php-lint.yml@v2
test:
needs: lint
strategy:
fail-fast: false
matrix:
php: ["8.3", "8.4", "8.5"]
uses: my-org/ci-templates/.github/workflows/php-test.yml@v2
with:
php-version: ${{ matrix.php }}
mysql-version: "8.4"
secrets: inherit
assets:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
- run: npm ci && npm run build
deploy:
needs: [test, assets]
if: github.ref == 'refs/heads/main'
uses: my-org/ci-templates/.github/workflows/deploy-php.yml@v2
secrets: inherit
Keep asset builds out of the PHP matrix. Node installs triple your bill for zero extra signal on a typical Blade/Vite 8.x app. Cache Composer and npm directories keyed on lockfile hashes. actions/cache@v4 with composer.lock and package-lock.json paths saves minutes on every push.
For teams comparing platforms, read our GitHub Actions vs GitLab CI comparison for 2026. GitLab CI extends and include files solve a similar DRY problem. The syntax differs. The architecture does not.
Permissions deserve explicit blocks when reusable deploy workflows use OIDC. The caller must set id-token: write and contents: read at workflow or job level. Reusable workflows do not inherit caller permissions by default in all contexts—check the current GitHub Actions token security guide when upgrading.
What are common mistakes with reusable workflows and matrix builds?
Most failures I debug on client migrations are structural—not syntax typos.
- Unbounded cross products. Three PHP versions times four OS targets times three database versions equals 36 cells. Filter aggressively. Test OS variance only where it matters.
- Secrets not forwarded. Reusable workflows need explicit
secrets:mapping orinherit: true. MissingCOMPOSER_AUTHfor private Packagist shows up as obscure 403 errors mid-composer install. - Outputs not wired. Reusable workflows expose job outputs via
workflow_call.outputs. Callers read them withneeds.job-id.outputs.name. Skipping this forces hacky artifact passing for simple version strings. - Concurrency ignored. Add
concurrency: group: … cancel-in-progress: trueon pull request workflows. Matrix runs multiply quickly. Without cancellation, five pushes mean five full grids. - Reusable workflow in the same repo. Reference it as
./.github/workflows/name.yml. Cross-repo usesowner/repo/.github/workflows/name.yml@ref. Mixing the two formats produces path errors that look like missing files.
Runner cost adds up on private repos. GitHub-hosted minutes for a 36-cell matrix on every commit can exceed Rs 15,000/month (~USD 110) on busy teams. Self-hosted runners on Ubuntu 24 LTS—managed through proper Linux system administration—can pay back quickly at scale.
When pipelines break after a template bump, treat it like any production incident. Roll the caller ref back to the previous tag. Fix forward in the template repo. Document the change in a changelog clients read before bumping v2 to v3. The same discipline applies to build pipeline automation best practices on any CI platform.
For quality gates beyond unit tests, wire reusable workflows into testing and optimization services that add browser tests or Lighthouse budgets as optional matrix dimensions—one include row, not a default column.
Key Takeaways
- Define reusable workflows with
on: workflow_call, typed inputs, and pinned refs—never floating branches in production callers. - Place the matrix on the caller when apps need different PHP floors; place it in the template when the platform team owns compatibility policy.
- Keep Node/Vite builds and deploy jobs outside the PHP test matrix to control runner cost and complexity.
- Forward secrets explicitly, set OIDC permissions on deploy callers, and cap parallel cells with
max-parallel. - Pair test reusable workflows with tagged release workflows and OIDC deploy patterns for a complete pipeline without YAML duplication.
- Audit matrix size before merge—a cross product above nine cells deserves an explicit team approval and cost note.
People Also Ask
Can a reusable workflow call another reusable workflow?
Yes. Nested reusable workflows are supported within GitHub's documented depth limit. A common pattern is a top-level CI caller that invokes separate reusable test and deploy workflows. Keep deploy out of the test matrix unless each cell truly needs an independent deployment target.
What is the maximum matrix size in GitHub Actions?
GitHub enforces a job limit per workflow run—currently 256 jobs including matrix expansions. Large cross products hit this ceiling silently during workflow parsing. Use exclude, split into multiple workflows, or shard test suites across jobs when approaching the limit.
How do reusable workflows share variables between jobs?
Inside a reusable workflow, use job outputs and needs dependencies—the same as ordinary workflows. To pass data back to the caller, define outputs on workflow_call and map them from job outputs. Environment variables set at workflow level are visible to all jobs in that reusable run.
Are reusable workflows available on GitHub Free plans?
Reusable workflows work on public repositories at all plan tiers. Private repositories need GitHub Team or Enterprise for calling reusable workflows across repository boundaries. Same-repo reusable calls work on Free private repos. Check your org plan before centralising templates in a private ci-templates repo.
Ship DRY CI without losing control
GitHub Actions Reusable Workflows and Matrix Builds turn CI from copy-pasted YAML into a maintainable platform layer. Start with one reusable PHP test workflow, add a caller-side matrix for the versions your Laravel app actually supports, and pin every cross-repo reference to a semver tag. Expand to deploy templates once tests are stable. If you want help designing a pipeline for a Laravel, WordPress, or custom PHP product—or migrating from GitLab CI without downtime—contact us for a CI architecture review or browse related guides on Terraform CI/CD with GitHub Actions and custom software development.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

