- Overview
- Installation
- How to use
- Creating a container
- Running a container
- Running if not exists
- Running to completion
- Checking if the container was reused
- Setting network
- Setting port mappings
- Setting volume mappings
- Setting tmpfs mounts
- Setting environment variables
- Disabling auto-remove
- Setting the image pull timeout
- Copying files to a container
- Stopping a container
- Stopping on shutdown
- Executing commands after startup
- Wait strategies
- MySQL container
- Flyway container
- Handling failures
- Using a custom Docker client
- Usage examples
- FAQ
- License
- Contributing
Manages Docker containers programmatically for PHP, enabling integration tests and disposable infrastructure to be orchestrated directly from code. Supports container definitions, port and volume mappings, environment variables, readiness probes, one-shot containers run to completion, shutdown hooks, and orphan reaping.
Container definitions are immutable: every with* method returns a new definition and leaves the receiver
untouched, so a base definition can be shared and specialized safely. Durations are expressed with Duration from
tiny-blocks/time.
composer require tiny-blocks/docker-containerCreates a container definition from an image. The container gets a unique generated name until withName() replaces
it.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\GenericDockerContainer;
$container = GenericDockerContainer::from(image: 'php:8.5-fpm')->withName(name: 'my-container');Since definitions are immutable, keep the value each with* method returns:
$container = $container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing');Creates and starts a container, optionally with the command to run on startup. The returned DisposableContainer is
owned by this process, so it can be removed once the work is done (see Stopping on shutdown).
$container->run();With commands:
$container->run(commands: ['ls', '-la']);To wait after the container starts, register a wait strategy on the definition (see Wait strategies).
A container that fails to receive its files, to start, or to satisfy a wait strategy is removed before the failure is raised, since nothing else holds it. A creation that fails after Docker registered the container, for example when the image pull outlasts the pull timeout, removes that container too.
Starts a container only if a container with the same name does not exist yet, and reuses it otherwise. The returned
SharedContainer is shared by every PHP process that asks for the same name (as in mutation testing), so it offers
neither stopOnShutdown() nor remove(): no single process owns it. When the code runs inside Docker, the first
container a process creates also starts a reaper container for that process. Once the test runner container exits,
the reaper removes every container the process created, in any state, and the networks it created that nothing uses
anymore. It finds them by the label of the process (see Stopping on shutdown), so the
containers and networks of other processes are never touched. The wait strategies registered with
withWaitAfterStarted() apply only when a new container starts.
Processes that ask for the same name at the same time are safe: Docker lets only one of them create the container, and the others reuse it. A container another process has created but not started yet is handed over only once it has started, so its address already carries the ports Docker assigned.
$container->runIfNotExists();Runs a one-shot container until it exits and returns what it wrote to its standard output. The container is created,
receives the files registered with copyToContainer(), starts attached, and is removed once it exits, successfully or
not, or once the timeout elapses.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\GenericDockerContainer;
use TinyBlocks\Time\Duration;
$execution = GenericDockerContainer::from(image: 'alpine:3.22')
->runToCompletion(timeout: Duration::fromSeconds(seconds: 60), commands: ['sh', '-c', 'echo done']);
$execution->getOutput(); # "done\n"A non-zero exit raises ContainerExitUnsuccessful carrying what the container wrote to its standard error. A
container still running when the timeout elapses raises ContainerWaitTimeout.
The removal runs in the PHP process that started the container. If that process is killed while the container runs, the container is left behind.
Tells whether the shared container came from an already-running instance. This distinguishes a fresh start from a
reuse, so first-boot work such as seeding runs only once. Only the SharedContainer returned by runIfNotExists()
answers it, since a container returned by run() is always new.
$started = $container->runIfNotExists();
$alreadyRunning = $started->wasReused();Sets the Docker network the container should join. The network is created automatically when the container runs, if
it does not already exist. Networks created by the library are labeled with tiny-blocks.docker-container=true and
with the label of the process that created them, for safe cleanup. When a cleanup running in parallel removes the network before the container starts, the network is
created again and the container is started once more.
$container = $container->withNetwork(name: 'my-network');Maps a port from the host to the container. A host port of 0 lets Docker pick a free one, which avoids collisions
between parallel runs.
$container = $container->withPortMapping(portOnHost: 8080, portOnContainer: 80);After the container starts, both ports are available through the Address:
$ports = $started->getAddress()->getPorts();
$ports->firstExposedPort(); # 80 (container-internal)
$ports->firstHostPort(); # 8080 (host-accessible)Mounts a directory from the host into the container.
$container = $container->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/container/data');Mounts a directory inside the container in memory. It starts empty, and its content is discarded when the container stops.
$container = $container->withTmpfsMount(pathOnContainer: '/container/cache');Adds an environment variable to the container.
$container = $container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing');By default, containers are removed when stopped. This disables that behavior.
$container = $container->withoutAutoRemove();Bounds how long creating the container may take, which includes pulling the image when it is not available locally. The default is 300 seconds. Raise it for large images on slow connections.
<?php
declare(strict_types=1);
use TinyBlocks\Time\Duration;
$container = $container->withPullTimeout(timeout: Duration::fromSeconds(seconds: 600));Registers files or directories to be copied from the host into the container. The files are copied after the
container is created and before it starts, so its command already sees them. A copy that fails raises
DockerCommandExecutionFailed and removes the container that was created, so it never starts without its files.
$container = $container->copyToContainer(pathOnHost: '/path/to/files', pathOnContainer: '/path/in/container');Stops a running container. Docker forces the stop once the grace period elapses.
<?php
declare(strict_types=1);
use TinyBlocks\Time\Duration;
$started = $container->run();
$result = $started->stop(gracePeriod: Duration::fromSeconds(seconds: 10));Registers a container started by run() to be forcefully removed when the PHP process exits. On shutdown, the
following cleanup is performed automatically:
- The container is killed and removed (
docker rm --force --volumes). - Anonymous volumes created by the container (e.g., MySQL's
/var/lib/mysql) are removed. - Unused networks created by this process are pruned.
Every container and network the library creates carries the label tiny-blocks.docker-container=true and the label of
the process that created it, tiny-blocks.docker-container.run=<host name>-<process ID>. Only resources carrying the
label of this process are affected. Containers, volumes, and networks of other processes and other environments are
never touched.
$started = $container->run();
$started->stopOnShutdown();Runs commands inside an already-started container.
$started = $container->run();
$result = $started->executeAfterStarted(commands: ['php', '-v']);The returned ExecutionCompleted provides the command output and success status:
$result->getOutput();
$result->isSuccessful();Wait strategies are part of the definition. withWaitBeforeRun() adds a ContainerWaitBeforeStarted, applied before
the container runs, and withWaitAfterStarted() adds a ContainerWaitAfterStarted, applied after run() starts the
container. Each method adds to the strategies already registered, and they apply in the order they were added.
Pauses for a fixed duration before a container runs or after it starts.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime;
use TinyBlocks\Time\Duration;
$pause = ContainerWaitForTime::forDuration(duration: Duration::fromSeconds(seconds: 3));
$container = $container
->withWaitBeforeRun(wait: $pause)
->withWaitAfterStarted(wait: $pause);Checks a readiness condition until it holds, pausing 250 milliseconds after each check that fails, and raises
ContainerWaitTimeout once the timeout (30 seconds by default) elapses. The time each check takes counts toward the
timeout. This is useful when one container depends on another being fully ready.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\GenericDockerContainer;
use TinyBlocks\DockerContainer\MySQLDockerContainer;
use TinyBlocks\DockerContainer\Waits\Conditions\MySQL\MySQLReady;
use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency;
use TinyBlocks\Time\Duration;
$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
->withRootPassword(rootPassword: 'root')
->run();
$container = GenericDockerContainer::from(image: 'my-app:latest')
->withWaitBeforeRun(
wait: ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $mySQLStarted))
->withTimeout(timeout: Duration::fromSeconds(seconds: 60))
)
->run();MySQLDockerContainer provides a specialized container for MySQL databases. Both run() and runIfNotExists()
hand the container over only once MySQL accepts connections, and create the database and the root grants when they
are configured. A setup MySQL rejects raises DockerCommandExecutionFailed carrying the MySQL error. A container
from run() that never accepts connections, or whose setup fails, is removed before the failure is raised. Both
return a MySQLContainerStarted: the one from run() is also a DisposableContainer, and the one from
runIfNotExists() is also a SharedContainer.
| Method | Parameter | Description |
|---|---|---|
withTimezone |
$timezone |
Sets the container timezone (e.g., America/Sao_Paulo). |
withUsername |
$user |
Sets the MySQL user created on startup. |
withPassword |
$password |
Sets the password for the MySQL user. |
withDatabase |
$database |
Sets the default database created on startup. |
withRootPassword |
$rootPassword |
Sets the root password for the MySQL instance. |
withGrantedHosts |
$hosts |
Sets hosts granted root privileges (default: ['%', '172.%']). |
withPullTimeout |
$timeout |
Sets how long creating the container, pull included, may take. |
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\MySQLDockerContainer;
$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
->withName(name: 'my-database')
->withDatabase(database: 'my_database')
->withPassword(password: 'secret')
->withTimezone(timezone: 'America/Sao_Paulo')
->withUsername(user: 'app_user')
->withPortMapping(portOnHost: 0, portOnContainer: 3306)
->withGrantedHosts()
->withRootPassword(rootPassword: 'root')
->run();Configures how long run() and runIfNotExists() wait for MySQL to accept connections before throwing a
ContainerWaitTimeout exception. The default timeout is 60 seconds.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\MySQLDockerContainer;
use TinyBlocks\Time\Duration;
$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
->withName(name: 'my-database')
->withRootPassword(rootPassword: 'root')
->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 60))
->run();Keeps the MySQL data in memory. The data directory /var/lib/mysql becomes a tmpfs mount, and the server starts
without the binary log (--skip-log-bin), which would otherwise grow in that memory. MySQL initializes faster, and
statements that write, such as the TRUNCATE and INSERT a test runs between cases, no longer wait for the disk.
The data is lost when the container stops, so use it only for databases a test run creates and throws away.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\MySQLDockerContainer;
$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
->withName(name: 'test-database')
->withDatabase(database: 'test_adm')
->withRootPassword(rootPassword: 'root')
->withEphemeralStorage()
->runIfNotExists();The server arguments come after any command given to run() or runIfNotExists(), so they also apply when the
command names mysqld explicitly.
After the MySQL container starts, connection details are available through the MySQLContainerStarted instance.
<?php
declare(strict_types=1);
$address = $mySQLStarted->getAddress();
$ip = $address->getIp();
$hostname = $address->getHostname();
$ports = $address->getPorts();
$containerPort = $ports->firstExposedPort(); # e.g. 3306 (container-internal)
$hostPort = $ports->firstHostPort(); # e.g. 49153 (host-accessible)
$environmentVariables = $mySQLStarted->getEnvironmentVariables();
$database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE');
$username = $environmentVariables->getValueBy(key: 'MYSQL_USER');
$password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD');
$jdbcUrl = $mySQLStarted->getJdbcUrl();Use firstExposedPort() when connecting from another container in the same network.
Use firstHostPort() when connecting from the host machine (e.g., tests running outside Docker).
The Address and Ports contracts provide environment-aware methods that automatically resolve the correct host and
port for connecting to a container. These methods detect whether the caller is running inside Docker or on the host
machine:
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\MySQLDockerContainer;
$started = MySQLDockerContainer::from(image: 'mysql:8.4')
->withName(name: 'my-database')
->withDatabase(database: 'my_database')
->withPortMapping(portOnHost: 0, portOnContainer: 3306)
->withRootPassword(rootPassword: 'root')
->runIfNotExists();
$address = $started->getAddress();
$host = $address->getHostForConnection(); # hostname inside Docker, 127.0.0.1 on host
$port = $address->getPorts()->getPortForConnection(); # container port inside Docker, host-mapped port on hostThis is useful when the same test suite runs both locally (inside a Docker Compose stack) and in CI (on the host).
FlywayDockerContainer runs Flyway database migrations. Each command runs Flyway once in a fresh container with a
unique generated name, blocks until Flyway exits, and removes the container afterward, so parallel test runs never
collide on it.
Binds Flyway to a MySQL container definition. Defining the source starts nothing. The first Flyway command starts the
MySQL container through runIfNotExists(), which reuses a running container of the same name, and waits until MySQL
accepts connections. Flyway then connects with the JDBC URL of that container, the root credentials (from
MYSQL_ROOT_PASSWORD), the target schema (from MYSQL_DATABASE), and the history table (schema_history), so it
needs no connection retries and no readiness wait of its own.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\FlywayDockerContainer;
use TinyBlocks\DockerContainer\MySQLDockerContainer;
$mySQL = MySQLDockerContainer::from(image: 'mysql:8.4')
->withName(name: 'test-database')
->withNetwork(name: 'my-network')
->withDatabase(database: 'test_adm')
->withRootPassword(rootPassword: 'root');
$flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
->withSource(container: $mySQL)
->withNetwork(name: 'my-network')
->withMigrations(pathOnHost: '/path/to/migrations');The credentials, the schema, and the table can be overridden before or after calling withSource():
<?php
declare(strict_types=1);
$flyway = $flyway
->withTable(table: 'custom_history')
->withSchema(schema: 'custom_schema')
->withSource(container: $mySQL)
->withCredentials(password: 'secret', username: 'app_user');Sets the host directory containing Flyway migration SQL files. The files are copied into the container at
/flyway/migrations before Flyway starts.
$flyway = $flyway->withMigrations(pathOnHost: '/path/to/migrations');| Method | Parameter | Description |
|---|---|---|
withTable |
$table |
Overrides the history table name (default: schema_history). |
withSchema |
$schema |
Overrides the target schema (default: detected from MySQL). |
withTimeout |
$timeout |
Sets how long each command waits for Flyway to exit (120 s). |
withPullTimeout |
$timeout |
Sets how long creating the container, pull included, may take. |
withCredentials |
$password, $username |
Overrides the credentials (default: root, from MySQL). |
withConnectRetries |
$retries |
Sets the number of database connection retries. |
withMigrationNamingValidation |
Rejects migrations whose file names break the naming convention. | |
withEnvironmentVariable |
$key, $value |
Sets any other Flyway setting through its FLYWAY_* variable. |
Flyway starts with a JVM tuned for short runs, JAVA_ARGS=-XX:TieredStopAtLevel=1 -XX:+UseSerialGC, which roughly
halves the processor time each run spends before Flyway reaches the database. Setting JAVA_ARGS replaces it:
$flyway = $flyway->withEnvironmentVariable(key: 'JAVA_ARGS', value: '-Xmx512m');| Method | Flyway command | Description |
|---|---|---|
migrate() |
migrate |
Applies pending migrations. |
validate() |
validate |
Validates applied migrations against local. |
repair() |
repair |
Repairs the schema history table. |
cleanAndMigrate() |
clean migrate |
Enables clean for the run, drops all objects, then re-migrates. |
$flyway->migrate();
$flyway->cleanAndMigrate();Each command blocks until Flyway exits, so its work is done when the method returns, and returns the Flyway output
as an ExecutionCompleted. It waits up to 120 seconds by default:
<?php
declare(strict_types=1);
use TinyBlocks\Time\Duration;
$flyway->withTimeout(timeout: Duration::fromSeconds(seconds: 300))->cleanAndMigrate();cleanAndMigrateSchemas() cleans and migrates a list of schemas in a single Flyway container. Each schema ends exactly
as withSchema($schema)->cleanAndMigrate() leaves it, cleaned, migrated, and with its own history table, while the
container is created, the migrations are copied, and the MySQL source is checked once for all of them instead of once
per schema. The schemas are migrated in order, and the first one Flyway fails on stops the run with
ContainerExitUnsuccessful. The run waits up to the timeout of withTimeout() for each schema, so 120 seconds per
schema by default. An empty list raises SchemaListEmpty.
$flyway->cleanAndMigrateSchemas(schemas: ['test_adm', 'test_adm_0', 'test_adm_1']);onlyWhenEnabled() returns a FlywayContainerGated, whose commands run only while an environment
variable holds 1. The variable is read when a command is called. While the variable holds anything else, or is not
set, the command returns null and issues no Docker command, so neither Flyway nor its MySQL source is started.
<?php
declare(strict_types=1);
$execution = $flyway
->onlyWhenEnabled(variable: 'RUN_MIGRATIONS')
->cleanAndMigrate(); # ExecutionCompleted when RUN_MIGRATIONS=1, null otherwiseThe gated container offers the same five commands, with the same exceptions. A command returns an
ExecutionCompleted only when Flyway did run, and null tells the caller that nothing ran.
The exceptions consumers may catch live in TinyBlocks\DockerContainer\Exceptions.
| Exception | Raised when |
|---|---|
DockerCommandExecutionFailed |
A Docker command cannot run, exceeds its timeout, or reports a failure. |
ContainerWaitTimeout |
A readiness condition does not hold, or a one-shot container does not exit, in time. |
ContainerExitUnsuccessful |
A one-shot container, such as Flyway, exits with a non-zero status. |
ImageNameEmpty |
A container is created from an empty image name. |
SchemaListEmpty |
cleanAndMigrateSchemas() is called with an empty list of schemas. |
ContainerExitUnsuccessful carries what the container wrote to its standard error, so a failed migration reports the
Flyway error itself:
Container <2xG1...> did not exit successfully. Output: <ERROR: Validate failed: Migrations have failed validation>.
Every factory accepts a DockerClient, the single boundary through which the library executes Docker commands. It
defaults to the local docker executable. A custom client can route the commands to another Docker-compatible engine,
or stand in for the engine in the unit tests of code that builds containers. A DockerCommand carries only its
arguments and runs through execute(), within the default timeout of the client. A DockerCommandWithTimeout, such as
a container creation, a container run to completion, or a stop, also carries the timeout it must finish within and runs
through executeWithTimeout():
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\DockerClient;
use TinyBlocks\DockerContainer\DockerCommand;
use TinyBlocks\DockerContainer\DockerCommandWithTimeout;
use TinyBlocks\DockerContainer\ExecutionCompleted;
use TinyBlocks\DockerContainer\GenericDockerContainer;
final readonly class RecordingDockerClient implements DockerClient
{
public function execute(DockerCommand $command): ExecutionCompleted
{
# Inspect $command->toArguments(), then answer with an ExecutionCompleted.
}
public function executeWithTimeout(DockerCommandWithTimeout $command): ExecutionCompleted
{
# Inspect $command->toArguments() and $command->timeout(), then answer with an ExecutionCompleted.
}
}
$container = GenericDockerContainer::from(image: 'alpine:3.22', client: new RecordingDockerClient());- When running the containers from the library on a host (your local machine), map the volume
/var/run/docker.sock:/var/run/docker.sockso the container has access to the Docker daemon on the host machine. - In some cases, it may be necessary to add the
docker-clidependency to your PHP image to interact with Docker from within the container.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\FlywayDockerContainer;
use TinyBlocks\DockerContainer\MySQLDockerContainer;
$mySQL = MySQLDockerContainer::from(image: 'mysql:8.4')
->withName(name: 'test-database')
->withNetwork(name: 'my-network')
->withDatabase(database: 'test_adm')
->withPassword(password: 'secret')
->withTimezone(timezone: 'America/Sao_Paulo')
->withGrantedHosts()
->withRootPassword(rootPassword: 'root');
FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
->withSource(container: $mySQL)
->withNetwork(name: 'my-network')
->withMigrations(pathOnHost: '/path/to/migrations')
->withMigrationNamingValidation()
->cleanAndMigrate();
$mySQLStarted = $mySQL->runIfNotExists();MySQL starts when Flyway runs. The last call hands over the same container, reused rather than started again, for the tests that need its address or its environment variables.
A PHPUnit bootstrap runs once per PHP process, and mutation testing starts many processes for one test run. The
pattern below starts MySQL and migrates it only in the process that asks for it (RUN_MIGRATIONS=1), while every
process derives the same database host without calling Docker. In every other process, onlyWhenEnabled() makes
cleanAndMigrate() return null before any Docker command, so the whole chain runs unchanged in each process. The
container name is scoped to the test runner (gethostname() is the runner container ID inside Docker), so two runs in
parallel never share, clean, or reap each other's database.
<?php
declare(strict_types=1);
use TinyBlocks\DockerContainer\FlywayDockerContainer;
use TinyBlocks\DockerContainer\MySQLDockerContainer;
$network = (string)(getenv('TEST_NETWORK') ?: 'my-service-test_default');
$template = 'my-service-database-%s';
$databaseHost = sprintf($template, gethostname());
putenv(sprintf('DATABASE_HOST=%s', $databaseHost));
putenv('DATABASE_PORT=3306');
FlywayDockerContainer::from(image: 'flyway/flyway:13.9.0-alpine')
->withSource(
container: MySQLDockerContainer::from(image: 'mysql:8.4')
->withName(name: $databaseHost)
->withNetwork(name: $network)
->withDatabase(database: 'my_service_test')
->withRootPassword(rootPassword: 'root')
->withEphemeralStorage()
)
->withNetwork(name: $network)
->withMigrations(pathOnHost: sprintf('%s/../database/migrations', __DIR__))
->withMigrationNamingValidation()
->onlyWhenEnabled(variable: 'RUN_MIGRATIONS')
->cleanAndMigrate();When each process of the run works on a schema of its own, as mutation threads do, migrate the main schema and the schema of every thread in one Flyway run instead of one run per schema:
<?php
declare(strict_types=1);
$schemas = ['my_service_test'];
for ($thread = 0; $thread < (int)getenv('MUTATION_THREADS'); $thread++) {
$schemas[] = sprintf('my_service_test_%d', $thread);
}
$flyway->onlyWhenEnabled(variable: 'RUN_MIGRATIONS')->cleanAndMigrateSchemas(schemas: $schemas);runIfNotExists() shares a container among every process that asks for the same name. With a fixed name, two test
runs in parallel (two terminals, two CI jobs on one host) share one database: one run cleans the schema the other is
using, and the first run to finish reaps the container from under the other. Suffixing the name with gethostname()
keeps the sharing inside one run, because inside Docker the host name is the ID of the runner container, unique per
docker run, and still identical in every process that run starts.
A container from run() belongs to the process that started it, so that process may remove it. A container from
runIfNotExists() is shared by every process of a run, so no single process may remove it, and only a shared
container can have been reused. Each type offers only what holds for it, instead of one contract whose methods do
nothing or always answer the same on one side.
Robert C. Martin, Agile Software Development, Principles, Patterns, and Practices (Prentice Hall, 2002), Chapter 12, "The Interface Segregation Principle".
Docker container is licensed under MIT.
Please follow the contributing guidelines to contribute to the project.