I'll walk you through spinning up a real MySQL service container inside a GitHub Actions workflow and connecting your PHP test suite to it. By the end, every push runs your database tests against an actual MySQL instance — no mocking, no SQLite shims — and the container is torn down automatically when the job finishes.
Prerequisites
- A GitHub repository with a PHP project that has database-driven tests.
composer.jsonandcomposer.lockcommitted at the repository root.- A test suite runnable with a single command, e.g.
./vendor/bin/phpunit. - The
pdo_mysqlPHP extension available —shivammathur/setup-phpinstalls it on request, which I cover in Step 3. - GitHub Actions enabled for the repository.
- An
ubuntu-latestrunner with the default Bash shell — service containers require a Linux runner.
Step 1 — Understand how GitHub Actions service containers work
GitHub Actions can spin up Docker containers alongside your job using the services key. These containers share a network with the job runner, so you can reach MySQL at 127.0.0.1 on whatever port you map. The container starts before any steps run and is stopped automatically when the job ends.
The critical detail is the healthcheck. MySQL takes a few seconds to be ready for connections after the container starts. Without a healthcheck, your first step that tries to connect hits a refused connection. GitHub Actions waits for the healthcheck to pass before it considers the service ready, so I always define one explicitly.
Note: Service containers are only available on Linux runners (
ubuntu-latest,ubuntu-22.04, etc.). They do not work onwindows-latestormacos-latestrunners. If you need cross-platform testing, run the database steps only in a Linux job.
Step 2 — Add the MySQL service container to the workflow
I define the MySQL service under the services key at the job level. I pin to mysql:8.0 rather than mysql:latest so the workflow doesn't silently break when a new major version ships.
mkdir -p .github/workflowscat > .github/workflows/tests.yml <<'YAML' name: Tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
text
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
MYSQL_USER: test_user
MYSQL_PASSWORD: test_password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=5
YAML
Breaking down the key parts:
MYSQL_ROOT_PASSWORD— required by the MySQL image; without it the container refuses to start.MYSQL_DATABASE— MySQL creates this database automatically on first boot, so you don't need a separateCREATE DATABASEstep.MYSQL_USER/MYSQL_PASSWORD— a non-root user for your application. Using root in tests is convenient but non-root is closer to production.ports: - 3306:3306— maps the container's MySQL port to the runner's localhost so PHP can reach it at127.0.0.1:3306.options: --health-cmd— GitHub Actions pollsmysqladmin pinguntil it succeeds before marking the service ready. Without this, your install step may start before MySQL accepts connections.
Step 3 — Add the job steps
Now I add the steps that check out the code, set up PHP with the pdo_mysql extension, install dependencies, and run the tests. The Composer cache step is included to keep repeated runs fast.
cat > .github/workflows/tests.yml <<'YAML' name: Tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
text
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
MYSQL_USER: test_user
MYSQL_PASSWORD: test_password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_mysql
coverage: none
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer downloads
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-php-8.3-composer-${{ hashFiles('composer.lock') }}
restore-keys: |
${{ runner.os }}-php-8.3-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run tests
env:
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: test_db
DB_USERNAME: test_user
DB_PASSWORD: test_password
run: ./vendor/bin/phpunit
YAML
I pass the database credentials to the test step as environment variables rather than hardcoding them in a config file. Your test bootstrap or framework configuration reads these at runtime. If you use a .env file locally, map the same variable names here so the test suite works identically in both environments.
Gotcha: Use
127.0.0.1as the host, notlocalhost. On Linux,localhostresolves to a Unix socket by default, which doesn't exist on the runner. The127.0.0.1address forces a TCP connection to the mapped port.
Step 4 — Run database migrations before the tests
The MySQL container starts with an empty test_db database. Your tests need the schema in place before they run. How you apply it depends on your project's tooling — I'll cover the three most common patterns.
Option A — Framework migration command (Laravel / Symfony)
- name: Run migrations env: DB_HOST: 127.0.0.1 DB_PORT: 3306 DB_DATABASE: test_db DB_USERNAME: test_user DB_PASSWORD: test_password run: php artisan migrate --force
Option B — Plain SQL file
- name: Import schema run: | mysql -h 127.0.0.1 -P 3306 -u test_user -ptest_password test_db \ < database/schema.sql
Option C — Phinx or another standalone migrator
- name: Run migrations env: DB_HOST: 127.0.0.1 DB_PORT: 3306 DB_DATABASE: test_db DB_USERNAME: test_user DB_PASSWORD: test_password run: ./vendor/bin/phinx migrate -e testing
Add whichever step matches your tooling immediately before the Run tests step in the workflow. The environment variables are the same in every case.
Step 5 — Push the workflow and verify the MySQL connection
Commit the completed workflow and push it to trigger the first run.
git add .github/workflows/tests.yml git commit -m "Add MySQL service container for PHP tests in CI" git push
Open the Actions tab and watch the Tests workflow. Before any steps execute, you'll see GitHub Actions initialising the service container. Once the healthcheck passes, the steps begin. Look for this in the service initialisation output:
Waiting for service 'mysql' to be healthy...
When the steps start, the Run tests step connects to 127.0.0.1:3306 with the credentials you set. If a connection failure appears, check two things first: confirm pdo_mysql is listed in the extensions field of the setup-php step, and confirm the host is 127.0.0.1 and not localhost.
Note: If your tests consistently fail with Connection refused despite the healthcheck, increase
--health-retriesto10and--health-intervalto15s. MySQL 8.0 initialisation can take longer on cold runners during peak GitHub infrastructure load.
Wrap-up
You now have a real MySQL instance running as a service container in GitHub Actions, connected to your PHP test suite on every push and pull request. The healthcheck ensures MySQL is accepting connections before your steps start, the schema is applied by your migration step, and the container is cleaned up automatically when the job ends. Pair this with the PHPStan and PHP-CS-Fixer workflows from the previous tutorials and your CI pipeline now enforces type safety, code style, and database-backed correctness in a single push.