beluga-php/docker-php
A PHP client for the Docker Engine API, offering a clean object-oriented way to manage containers, images, networks, and volumes programmatically. Ideal for building Docker-powered tooling, deployment scripts, and infrastructure workflows in PHP.
beluga-php/docker-php is a low-level, typed Docker Engine API client. It is not a Laravel package — there is no service provider, facade, or config file. You get a DockerClient and generated API models (ContainerConfig, HostConfig, Mount, PortBinding, EndpointSettings, …), and you wire it into Laravel yourself. The repo is marked archived, so treat it as vendored infrastructure rather than a dependency you can freely upgrade.
Install and pin an exact version (it is archived; do not rely on floating constraints):
composer require beluga-php/docker-php:^3 --no-update
# then commit the exact resolved version in composer.lock
Confirm the daemon is reachable before writing any application code. Docker::create() returns a DockerClient talking to the local socket by default:
use Docker\Docker;
$docker = Docker::create();
$info = $docker->systemInfo(); // sanity check: daemon alive?
$containers = $docker->containerList(['all' => true]);
foreach ($containers as $container) {
dump($container->getNames(), $container->getState());
}
First real use case: ephemeral companion services and CI-style checks from Artisan, not interactive container orchestration. A typical small Laravel app uses it to (1) list/inspect containers for a status page, (2) spin up a throwaway service (e.g. redis:7-alpine, a mock SMTP) for tests or a demo, and (3) stream logs or prune stale resources. Bind it once in AppServiceProvider so you don't hand-roll socket plumbing in every command:
use Docker\Docker;
use Docker\DockerClient;
$this->app->singleton(DockerClient::class, fn () => Docker::create());
Read the generated model classes under vendor/beluga-php/docker-php/src/API/Model early — that is the de facto documentation for what you can set on ContainerConfig/HostConfig.
Create → start → wait → remove. Container operations are always explicit and stateful; nothing auto-runs. This is the daily bread-and-butter sequence:
use Docker\API\Model\ContainerConfig;
use Docker\API\Model\HostConfig;
use Docker\API\Model\PortBinding;
$config = (new ContainerConfig())
->setImage('redis:7-alpine')
->setExposedPorts(['6379/tcp' => new \stdClass()])
->setHostConfig(
(new HostConfig())
->setPortBindings(['6379/tcp' => [(new PortBinding())->setHostPort('63790')]])
->setAutoRemove(true)
);
$created = $docker->containerCreate($config, 'app-redis-'.Str::random(6));
$docker->containerStart($created->getId());
$result = $docker->containerWait($created->getId(), 'not-running'); // exit code
Use containerCreate($config, $name) with your own name to make cleanup deterministic; otherwise the daemon assigns one and you must persist the ID.
Wrap the client in an application service. Keep DockerClient calls inside one class (App\Services\DockerService) or a small package of them. That gives you a single place for error handling, timeouts, and the eventual swap away from this archived library. Controllers and jobs then depend on your service, not on Docker\API\Model\* leaking across the codebase.
Streams must be consumed. Image pulls/builds (imageCreate, imageBuild) and containerLogs return streams, not arrays. Image operations are asynchronous from the daemon's perspective — if you never drain the stream, the pull appears to hang and the image may not finish. Iterate chunks (each JSON line in a pull stream carries status/progress/error); for logs, read in chunks or pipe to StreamedResponse:
return response()->stream(function () use ($docker, $id) {
$stream = $docker->containerLogs($id, [
'follow' => true, 'stdout' => true, 'stderr' => true, 'tail' => 200,
]);
while (($chunk = $stream->read(8192)) !== '') {
echo $chunk;
ob_flush(); flush();
}
}, 200, ['Content-Type' => 'text/plain', 'X-Accel-Buffering' => 'no']);
Queue the slow paths. Create/start/pull/build/wait are blocking network calls and can take tens of seconds. Put them in queued jobs (php artisan queue:work) and expose progress via broadcasting or a status row; never do a containerWait inside an HTTP request thread.
Ephemeral test fixtures. In tests/TestCase or a trait, boot the services your suite needs in setUpBeforeClass() and force-remove them in tearDownAfterClass(). Always remove by ID with force => true so a failed assertion doesn't leak containers across runs. Persist IDs/names in the trait's static state — do not try to "find" them again by image name, since that will match unrelated containers on a shared daemon.
Artisan commands for ops. Thin commands such as docker:status, docker:prune, and docker:logs {container} are a natural fit: they run in a terminal, tolerate blocking calls, and give you a scriptable interface for cron. Keep them read-only or dry-run by default; destructive pruning should require --force.
Health/status dashboards. containerList() + containerInspect() gives you state, health status, and port mappings; systemDf() (if your version exposes it) gives disk usage. Cache the result for 15–60 seconds — every call is a round trip to the daemon and these pages are usually polled.
docker compose up semantics, shelling out to the CLI or using testcontainers-php may be a better long-term bet.www-data, forge, the FPM pool user) must be able to read the socket. Adding it to the docker group is the common fix, but that is effectively root-equivalent access. Prefer a socket proxy (e.g. tecnativa/docker-socket-proxy) and point the client at it, especially on a shared host.Docker::create() talks to the local socket. Under Docker Desktop, WSL, or a remote host, the socket path and DOCKER_HOST/context differ; you will need to construct the HTTP client explicitly (Unix socket vs tcp://host:2376) and supply TLS certificates for remote daemons. Always run systemInfo() in a tinker session to prove you're talking to the daemon you think you are.containerLogs output frames stdout and stderr with an 8-byte header. If you see garbage/control bytes when dumping raw chunks, either enable Tty => true in ContainerConfig (loses the stdout/stderr split) or use the library's stream helper instead of echo-ing raw bytes into a browser.containerCreate fails if the name exists. Use a unique suffix for per-job containers, or catch the conflict and reuse/inspect the existing one. The same applies to published host ports — pick a free port instead of hardcoding one in tests.HostConfig. Port bindings, volume mounts, restart policies, and resource limits all live on HostConfig. Setting setExposedPorts() alone exposes the port inside the container network but does not publish it to the host; you need matching setPortBindings(). Same for Mount entries — a missing bind mount is silently a container-local filesystem.containerWait conditions. Passing the wrong condition (or omitting it on older versions) blocks until the container exits. For long-running services, poll containerInspect()->getState()->getStatus() from a job instead of waiting.Docker\Exception\DockerException (and its specific subclasses such as not-found variants) around every call, not just at the top level. A stopped container, a missing image, and a malformed config all surface differently, and the message includes the daemon's JSON — log it verbatim; it is far more useful than the exception class.setAutoRemove(true) only removes the container after it exits — it does not clean images, volumes, or networks. A periodic docker:prune command (dangling images, stopped containers, unused volumes beyond a retention window) prevents disk-fill incidents on long-lived daemons.DockerClient/DockerHttpClient and all models are plain POPOs. If you need Engine API features newer than the pinned models, a thin wrapper that issues raw requests is usually easier than forking the generated API/Model tree.How can I help you explore Laravel packages today?