PHP CI/CD gets less blog coverage than its Java or Node counterparts, which is odd given how much of the web still runs on it. When I set up Jenkins for a legacy Laravel monolith at a previous job, most of the pain wasn’t Jenkins itself – it was getting PHP’s dependency and testing tooling to behave predictably inside CI. Once that was sorted, the pipeline itself was refreshingly simple. Here’s the complete setup, from a bare Jenkins install to a full Composer-Test-Deploy pipeline.
Why Use Jenkins for PHP Projects
PHP projects benefit from CI/CD the same way any codebase does, but a few things matter more here specifically:
- Composer dependency consistency – locking down exact package versions across every environment.
- Multiple PHP version support – many teams still maintain code that must run against more than one PHP version (e.g., supporting a WordPress plugin across PHP 8.1-8.3).
- Static analysis – PHP’s dynamic typing makes tools like PHPStan or Psalm disproportionately valuable in catching bugs before runtime.
- Framework-specific tooling – Laravel, Symfony, and WordPress each have their own testing and deployment quirks that a pipeline needs to account for.
Step 1: Prerequisites on the Jenkins Agent
Your Jenkins agent needs PHP, Composer, and your testing tools installed. The cleanest way to guarantee consistency is to run builds inside a PHP Docker image rather than installing PHP directly on the agent host.
Step 2: Install Required Jenkins Plugins
From Manage Jenkins → Plugins, install:
- Git Plugin
- Docker Pipeline Plugin
- JUnit Plugin (PHPUnit can output JUnit-format XML)
- HTML Publisher Plugin (for code coverage reports)
- Warnings Next Generation Plugin (visualizes PHPStan/Psalm/PHP_CodeSniffer output)
java -jar jenkins-cli.jar -s http://localhost:8080/ install-plugin git docker-workflow junit htmlpublisher warnings-ng -restart
Step 3: Write the Jenkinsfile
Here’s a full pipeline for a Laravel-style PHP project, covering dependency installation, static analysis, unit tests, and coverage reporting:
pipeline {
agent {
docker {
image 'php:8.3-cli'
args '-v composer-cache:/tmp/composer-cache'
}
}
environment {
COMPOSER_CACHE_DIR = '/tmp/composer-cache'
APP_ENV = 'testing'
}
stages {
stage('Checkout') {
steps {
git branch: 'main', url: 'https://github.com/myorg/php-app.git'
}
}
stage('Install Dependencies') {
steps {
sh 'curl -sS https://getcomposer.org/installer | php'
sh 'php composer.phar install --no-interaction --prefer-dist --optimize-autoloader'
}
}
stage('Static Analysis') {
steps {
sh 'vendor/bin/phpstan analyse app --level=5 --error-format=checkstyle > phpstan-report.xml || true'
}
}
stage('Code Style Check') {
steps {
sh 'vendor/bin/phpcs --standard=PSR12 app --report=checkstyle --report-file=phpcs-report.xml || true'
}
}
stage('Run Tests') {
steps {
sh 'cp .env.testing .env'
sh 'php artisan key:generate'
sh 'php artisan migrate --force'
sh 'vendor/bin/phpunit --coverage-html coverage --log-junit junit-report.xml'
}
}
}
post {
always {
junit 'junit-report.xml'
recordIssues tools: [phpStan(pattern: 'phpstan-report.xml'), checkStyle(pattern: 'phpcs-report.xml')]
publishHTML(target: [
reportDir: 'coverage',
reportFiles: 'index.html',
reportName: 'PHPUnit Coverage Report'
])
}
}
}
Step 4: Testing Against Multiple PHP Versions
For libraries that need to support several PHP versions, use a matrix build:
pipeline {
agent none
stages {
stage('Test Matrix') {
matrix {
axes {
axis {
name 'PHP_VERSION'
values '8.1', '8.2', '8.3'
}
}
stages {
stage('Test') {
agent {
docker { image "php:${PHP_VERSION}-cli" }
}
steps {
sh 'curl -sS https://getcomposer.org/installer | php'
sh 'php composer.phar install --no-interaction'
sh 'vendor/bin/phpunit'
}
}
}
}
}
}
}
Step 5: Handling a Database in Tests
Most real PHP applications need a database for integration tests. Use Docker Compose or Jenkins’ docker agent with a linked service container:
pipeline {
agent {
docker {
image 'php:8.3-cli'
args '--network=ci-net'
}
}
stages {
stage('Start MySQL') {
steps {
sh 'docker run -d --name mysql-test --network=ci-net -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=app_test mysql:8.0'
sh 'sleep 15'
}
}
stage('Run Tests') {
steps {
sh 'DB_HOST=mysql-test DB_DATABASE=app_test vendor/bin/phpunit'
}
}
}
post {
always {
sh 'docker rm -f mysql-test || true'
}
}
}
Step 6: Deployment
A common PHP deployment pattern is rsync-based deployment to a traditional LAMP server, or building a Docker image for containerized environments.
Traditional server deployment (rsync + SSH):
stage('Deploy') {
when { branch 'main' }
steps {
sshagent(['prod-ssh-key']) {
sh '''
rsync -avz --delete \
--exclude=.env \
--exclude=storage \
./ deploy@prod-server:/var/www/app/
ssh deploy@prod-server "cd /var/www/app && composer install --no-dev --optimize-autoloader && php artisan migrate --force && php artisan config:cache"
'''
}
}
}
Containerized deployment:
stage('Build and Push Image') {
when { branch 'main' }
steps {
sh 'docker build -t myregistry.com/php-app:${BUILD_NUMBER} .'
sh 'docker push myregistry.com/php-app:${BUILD_NUMBER}'
}
}
Integrating with the Wider Toolchain
- Git/GitHub – trigger builds via webhook on push and PR, and post PHPUnit/PHPStan results back as PR status checks using the GitHub Checks plugin.
- Docker/Kubernetes – containerize the PHP app with a proper multi-stage Dockerfile (Composer install in a build stage, lean
php-fpmruntime in the final stage) for deployment to Kubernetes. - Maven-equivalent (Composer) – Composer is to PHP what Maven is to Java; commit
composer.lockand always install with--no-devin production builds to keep the deployed artifact lean. - Security tools – run
composer audit(built into recent Composer versions) orlocal-php-security-checkerin a dedicated pipeline stage to catch known vulnerable dependencies.
Monitoring and Troubleshooting
- “class not found” errors in CI but not locally – almost always a stale or missing
composer install --optimize-autoloader; regenerate the autoloader explicitly in CI rather than trusting a cached vendor directory. - Tests pass locally but fail in Jenkins – check
.env.testingvalues and confirm the database service container is fully ready before tests start (sleepor a proper healthcheck wait loop). - Composer install is slow – mount a persistent Composer cache volume (
COMPOSER_CACHE_DIR) across builds instead of downloading every package from scratch each run. - Memory limit errors during PHPUnit runs – increase
memory_limitin a customphp.iniused specifically for CI, since default CLI limits are often too low for large test suites with coverage enabled.
Security Best Practices
- Run
composer auditon every build and fail the pipeline on high-severity findings. - Never commit
.envfiles with real secrets; inject them via Jenkins Credentials at deploy time. - Use least-privilege SSH deploy keys scoped only to the deployment path, not full server access.
- Keep PHP versions current – PHP has a defined support lifecycle, and running end-of-life versions in production is a real, common risk.
Best Practices
- Cache the
vendor/directory or Composer cache directory between builds to cut install time significantly on larger projects. - Run static analysis (PHPStan/Psalm) and code style checks (PHP_CodeSniffer) on every PR, not just before release, so issues get caught early and cheaply.
- Separate “fast” unit tests from “slow” integration/database tests into different pipeline stages so quick feedback isn’t blocked by slower suites.
- Use
--no-devand--optimize-autoloaderfor any Composer install destined for production, since dev dependencies and unoptimized autoloading both hurt runtime performance.
FAQs
Does Jenkins support PHPUnit output natively? Yes, PHPUnit can output JUnit-format XML (--log-junit), which the standard JUnit Jenkins plugin reads directly without any special PHP-specific plugin.
Can I run WordPress plugin/theme tests in this same setup? Yes, using wp-cli and the WordPress test suite bootstrap inside your Docker image, following largely the same pattern shown above.
How do I handle Composer private repositories/packages? Store the Composer auth token (e.g., for a private Packagist or GitHub token) as a Jenkins Credential and export it as COMPOSER_AUTH before running composer install.
Is Xdebug needed for coverage reports? Yes, or alternatively PCOV, which is faster and purpose-built for coverage collection; install whichever extension your PHP Docker image doesn’t already include.
Can this pipeline support zero-downtime deployments? Yes, using a symlink-based release strategy (deploy to a timestamped directory, then atomically switch a current symlink), which is a common pattern for traditional PHP hosting.
Summary
Setting up Jenkins for PHP is mostly about getting Composer, PHPUnit, and static analysis tools running consistently inside a containerized PHP environment, then wiring their output into Jenkins’ native reporting via JUnit XML and the Warnings Next Generation plugin. From there, deployment can go either the classic rsync/SSH route for traditional hosting or a full Docker image build for containerized environments. Once this pipeline is in place, PHP’s reputation for “hard to test in CI” mostly disappears – it becomes just another well-behaved pipeline in your Jenkins setup.
References
- Jenkins Pipeline Syntax reference: https://www.jenkins.io/doc/book/pipeline/syntax/
- Composer documentation: https://getcomposer.org/doc/
- PHPUnit documentation: https://docs.phpunit.de/
- PHPStan documentation: https://phpstan.org/user-guide/getting-started
- Jenkins Warnings Next Generation Plugin: https://plugins.jenkins.io/warnings-ng/