Dev with Sail
Laravel Sail with an OPC UA test server sidecar. Docker Compose, the simplest possible dev loop, and the few gotchas around socket paths in containerised environments.
Laravel Sail is the lightest local-development environment for Laravel. Add an OPC UA test server as a sidecar and you have an end-to-end dev environment in two Docker images.
Add the OPC UA test server
In docker-compose.yml, add a service:
services:
laravel.test:
# ... existing Sail config
opcua-test:
image: ghcr.io/php-opcua/uanetstandard-test-suite:latest
ports:
- "${OPCUA_PORT:-4840}:4840" # unsecured
- "${OPCUA_SECURE_PORT:-4841}:4841" # secured
networks:
- sail
healthcheck:
test: ["CMD", "sh", "-c", "nc -z localhost 4840"]
interval: 5s
retries: 10
Bring it up:
./vendor/bin/sail up -d
Configure Laravel to point at it
In .env:
OPCUA_ENDPOINT=opc.tcp://opcua-test:4840
Note: the hostname is opcua-test (the Docker service name),
not localhost — Sail networks containers together.
For the secured endpoint:
OPCUA_ENDPOINT=opc.tcp://opcua-test:4841
OPCUA_SECURITY_POLICY=Basic256Sha256
OPCUA_SECURITY_MODE=SignAndEncrypt
OPCUA_CLIENT_CERT=/var/www/html/dev-certs/client.pem
OPCUA_CLIENT_KEY=/var/www/html/dev-certs/client.key
Generate dev certs:
mkdir -p dev-certs
./vendor/bin/sail exec laravel.test openssl req -x509 -newkey rsa:2048 \
-keyout dev-certs/client.key \
-out dev-certs/client.pem \
-days 365 -nodes \
-subj "/CN=Sail Dev/O=Local" \
-addext "subjectAltName=URI:urn:sail-dev:client"
dev-certs/ is gitignored by default. Don't commit dev keys.
Running the daemon under Sail
The daemon is just a long-running PHP process — it runs inside
the laravel.test container.
For interactive dev (start it when you need it):
./vendor/bin/sail artisan opcua:session
For backgrounded dev:
services:
opcua-daemon:
image: sail-8.4/app
extends: laravel.test
command: php /var/www/html/artisan opcua:session
depends_on:
opcua-test:
condition: service_healthy
volumes:
- .:/var/www/html
networks:
- sail
Now sail up brings up Laravel, the OPC UA test server, AND the
daemon. Three-container dev environment.
Connecting the dots
In .env (managed mode under Sail):
OPCUA_ENDPOINT=opc.tcp://opcua-test:4840
OPCUA_SESSION_MANAGER_ENABLED=true
OPCUA_SOCKET_PATH=/var/www/html/storage/framework/opcua-session-manager.sock
Both laravel.test and opcua-daemon see the same socket file
via the shared . volume.
A first test
./vendor/bin/sail artisan tinker
> Opcua::isSessionManagerRunning()
=> true
> Opcua::read('i=2256')->value
=> 0
i=2256 is the standard Server_ServerStatus_State node — value
0 means "Running". If you see this, the dev stack is wired.
Trust-store flow under Sail
The trust store lives at storage/app/opcua/trust/. Under Sail,
that's inside the laravel.test container but mapped to your
host:
# laravel-opcua does not ship opcua:trust:add — use opcua-cli.
./vendor/bin/sail bash -lc \
'OPCUA_TRUST_STORE_PATH=storage/app/opcua/trust \
vendor/bin/opcua-cli trust:add opc.tcp://opcua-test:4841'
The cert lands in storage/app/opcua/trust/<hash>.pem. Both your
host and the Sail container see it — convenient for editing
during dev.
Auto-publish in dev
For dev work on subscription / event flow:
OPCUA_AUTO_PUBLISH=true
Restart the daemon:
./vendor/bin/sail restart opcua-daemon
Now Opcua::subscribe() fires Laravel events that your dev
listeners receive.
Sail + Pest
Run tests as usual:
./vendor/bin/sail test # everything except Integration
./vendor/bin/sail test tests/Integration # Integration with the live test server
The integration tests target opcua-test automatically because
that's what .env points at.
Sail + Reverb
For broadcasting dev:
services:
reverb:
image: sail-8.4/app
extends: laravel.test
command: php /var/www/html/artisan reverb:start
ports:
- "${REVERB_PORT:-8080}:8080"
volumes:
- .:/var/www/html
networks:
- sail
REVERB_HOST=localhost # browser-visible
VITE_REVERB_HOST=localhost
REVERB_PORT=8080
Run:
./vendor/bin/sail up -d
./vendor/bin/sail npm run dev # Vite
Browse http://localhost. Real-time tag updates appear in the
browser as the test server's CurrentTime ticks (or whatever
tags you subscribe to).
Hot-reloading the daemon
PHP doesn't hot-reload. After editing daemon-touching code, kill and restart:
./vendor/bin/sail restart opcua-daemon
For listener changes, you don't need to restart the daemon — listener resolution happens per-event from the application's container, which gets re-bootstrapped each time.
Tearing down
./vendor/bin/sail down -v # -v drops volumes too
For a clean slate including the trust store:
./vendor/bin/sail down -v
rm -rf storage/app/opcua/trust
CI parity
The same docker-compose.yml runs in CI. The CI workflow:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker compose up -d
- run: docker compose exec -T laravel.test composer install
- run: docker compose exec -T laravel.test php artisan test
- run: docker compose down
Local dev and CI use the same fixtures — surprises are rare.
Other Sail-compatible test servers
| Image | When |
|---|---|
ghcr.io/php-opcua/uanetstandard-test-suite:latest |
Full coverage (8 endpoints) |
ghcr.io/php-opcua/extra-test-suite:latest |
open62541-backed, supports method calls |
mcr.microsoft.com/iotedge/opc-plc:latest |
Microsoft's open-source PLC simulator |
For most dev, the uanetstandard-test-suite is the right choice
— it exercises every OPC UA feature the package supports.
Where to read next
- Production deployment — same patterns, real iron.
- Integration tests — the full test-suite story.