Local Maho development with Harbor
Set up a complete Maho development environment from scratch with Harbor and learn to use it day by day: FrankenPHP, MySQL 8.4, Mailpit, cron, optional profiles and customizations, all from a single Docker Compose launcher.
Picture a harbor where your ship is already in the water, rigged and ready to sail. You did not
build the docks, the cranes or the lighthouses: someone else set them up for you. That is Harbor,
the Docker Compose launcher and development stack for Maho: an open source project by MageMEGA,
inspired by Laravel Sail, used in house and given to the community. A handful of commands build the
whole environment: the app (FrankenPHP with Caddy and PHP), a MySQL 8.4 database, Mailpit for
outgoing
email and Ofelia for cron. The harbor binary wraps docker compose, so you never write the
compose file or the docker compose commands by hand. Harbor already knows the generated file,
the profiles and the service names, and hands you short commands. Concretely:
# what you would type by hand every time...
docker compose -f .harbor/compose.yaml exec app php ./maho cache:flush
docker compose -f .harbor/compose.yaml exec app bash
# ...becomes simply
./vendor/bin/harbor maho cache:flush
./vendor/bin/harbor shell
⚓ Ship’s log. Harbor is a harbor, not the open sea. In production it would sink: its own README forbids production use. Here we are safely anchored, free to ram everything and rebuild from scratch whenever we need to.
Requirements
On the host you need:
- Docker with the Compose v2 plugin (Harbor prefers
docker composeand falls back todocker-compose). - PHP >= 8.3, only to run Composer with
create-projectandrequire. Everything else lives in the container, which ships PHP 8.4 by default.
Quick start: from zero to a running store
1. Create the project
Start from the official starter and add Harbor as a dev dependency:
composer create-project mahocommerce/maho-starter my-store
cd my-store
composer require --dev empiricompany/harbor # or :dev-main for the latest version
2. Initialize and start the stack
harbor init generates the .harbor/ directory with the project files: .env, compose.yaml,
docker.override.yaml and docker.install.yaml (the last two start out empty). Then build and
start the stack. The first build takes 5 to 20 minutes, the perfect moment to grab a coffee rather
than press Ctrl+C.
./vendor/bin/harbor init
./vendor/bin/harbor up -d --build
./vendor/bin/harbor ps
--build is needed every time you change the Dockerfile or the image’s PHP extensions. ps
confirms the containers are running.
3. Install the dependencies and Maho
Install the PHP dependencies inside the app container, then run the Maho installer:
./vendor/bin/harbor composer install
./vendor/bin/harbor maho install --license_agreement_accepted yes \
--locale en_US --timezone UTC --default_currency USD \
--db_host db --db_name maho --db_user maho --db_pass maho \
--url 'https://localhost:8443/' --use_secure 1 --secure_base_url 'https://localhost:8443/' \
--use_secure_admin 1 --admin_username admin --admin_password 'change-me-strong' \
--sample_data 1
A note on --url: it must match the URL configured in Harbor. If the two values disagree, Maho
stores base URLs pointing somewhere else and every generated link breaks.
4. Reindex, cache and first login
Finish with a reindex and a cache flush, then open the store:
./vendor/bin/harbor maho index:reindex:all && ./vendor/bin/harbor maho cache:flush
./vendor/bin/harbor open admin
The storefront is now on https://localhost:8443/ (HTTPS, with Caddy’s internal CA or a
self-signed certificate). harbor open opens the admin or Mailpit (open mailpit) in your
browser. To shorten the commands you can add a shell alias:
alias harbor='./vendor/bin/harbor'
How to use Harbor
Run harbor from the project root: the commands run inside the container, so you never install any
part of the application on your machine. harbor composer and harbor maho cover almost all the
daily work, and any command Harbor does not recognize is passed straight to Docker Compose. In
practice you are not learning a new tool: you are talking to the Docker Compose you already know,
with a butler that translates for you.
Lifecycle and diagnostics
./vendor/bin/harbor up -d [--build] # start (and rebuild) the stack
./vendor/bin/harbor ps # container status
./vendor/bin/harbor logs -f app # streaming logs
./vendor/bin/harbor stop [service] # stop
./vendor/bin/harbor restart [service] # restart
./vendor/bin/harbor down # stop and remove containers and networks
./vendor/bin/harbor config # show the final Compose configuration
./vendor/bin/harbor doctor # validate Docker, Compose and the stack
down -v is destructive: it also removes the volumes, so the database is wiped. The app writes
both the Caddy access log and the PHP errors to stdout/stderr, so everything shows up in a single
stream with logs app.
⚓ Ship’s log.
down -vis the button that sinks the ship with the whole crew, database included. Before pressing it, export a backup withharbor maho db:export: the harbor can be rebuilt, the data cannot.
When you need to get inside the containers:
./vendor/bin/harbor shell # bash in the app container
./vendor/bin/harbor shell --service db # bash in another service
./vendor/bin/harbor root-shell # bash as root in app
./vendor/bin/harbor exec --service app php -v
./vendor/bin/harbor mysql maho --execute='SHOW TABLES'
./vendor/bin/harbor redis --raw ping
For queries prefer harbor maho db:query; harbor mysql is for raw or interactive client work.
Composer and the Maho CLI
Composer runs through Harbor with a full passthrough (install, update, require, remove and
so on):
./vendor/bin/harbor composer update
./vendor/bin/harbor composer require vendor/package
harbor composer update reinstalls local path packages and republishes the module assets into
public/. If you edit a file under a package’s public/, re-run composer update to publish it
again.
The Maho CLI is harbor maho <command> and reads credentials from app/etc/local.xml. The
commands you will use most:
migrate: applies pending declarative schema and data updates. Run it after installing a module or changing its setup scripts.cache:flush: required after renaming blocks, layout handles or config aliases. The layout and config cache survives file changes and keeps serving stale XML.db:query "SELECT ...": the right way to run SQL, because it reads the credentials fromlocal.xml. Handy for checkingcore_resourceand the overrides incore_config_data.config:get|set|delete <path>: read and writecore_config_datavalues, resolving the scopes for you. Prefer these to hand-written SQL.db:export/db:import: dump and restore the database. Set the compression with--compression=gzip|zstdand use--drop-tablesto recreate the database before importing.health-check,admin:user:*,shell,accessibility:scan: diagnostics, admin management, a PHP shell with Maho bootstrapped, and an accessibility scan.
A typical cycle to install or update a local module and verify its setup:
./vendor/bin/harbor composer update
./vendor/bin/harbor maho migrate
./vendor/bin/harbor maho db:query "SELECT * FROM core_resource WHERE code LIKE '%<alias>%'"
./vendor/bin/harbor maho cache:flush
To bring the store back to a known state:
./vendor/bin/harbor maho db:export var/backups/backup.sql.gz --compression=gzip
./vendor/bin/harbor maho db:import var/backups/backup.sql.gz --compression=gzip --drop-tables
./vendor/bin/harbor maho cache:flush
Optional services and profiles
The base stack has four containers: app, db, mailpit and cron. Optional services are
managed with profiles, listed in .harbor/.env.
Built-in profiles:
redis: Redis server onredis:6379(from the host127.0.0.1:6379), with separate databases for sessions and cache.adminer: web UI onhttp://localhost:8082, with thedbserver pre-filled.phpmyadmin: web UI onhttp://localhost:8081, automatically signed in with the database credentials.cloudflaredandcloudflared-quick: Cloudflare tunnels, described below.
./vendor/bin/harbor services list
./vendor/bin/harbor services add phpmyadmin # or redis, adminer, cloudflared
./vendor/bin/harbor services add redis
./vendor/bin/harbor up -d # apply the profile change
./vendor/bin/harbor ps # verify
services add and services remove only update .harbor/.env: they do not start or stop
containers. This is also how you customize the stack: declare a service with profiles: [name] in
.harbor/docker.override.yaml and Harbor discovers and manages it like the built-in profiles. Omit
the profiles key entirely and the service becomes part of the default stack, starting on every
up.
Mail, cron and tunnels
Mailpit is a base service, always running: it is an SMTP catch-all, so every email the store
sends stays local. The UI is on http://localhost:8025. From the host the SMTP port is
localhost:1025, while applications inside the containers send to mailpit:1025. Mailpit has
no authentication: never expose it.
⚓ Ship’s log. Mailpit intercepts every outgoing email in the harbor: it holds it instead of sending it out into the world. Perfect for testing order confirmations and newsletters without spamming real customers.
The cron service runs Ofelia, which schedules Maho jobs through Docker labels. Start it
explicitly:
./vendor/bin/harbor cron
Two jobs are defined by default: maho-cron-always every minute (cron:run always) and
maho-cron-default every five minutes (cron:run default), both without overlap. If you do not
start Ofelia, Maho’s jobs stay asleep. To add your own jobs, use the ofelia.job-exec.<name>.*
labels in .harbor/docker.override.yaml, then run up -d to recreate the service and logs cron
to check it.
If you need to expose the store outside your machine without opening ports, Harbor ships the Cloudflare Tunnel profiles:
./vendor/bin/harbor tunnel quick --autoconfig # ephemeral *.trycloudflare.com URL
./vendor/bin/harbor tunnel down --autoconfig # restore the local URL
--autoconfig points Maho’s base URLs at the public URL and restores them on shutdown.
Configuration: what to change and where
The generated .harbor/compose.yaml is never edited by hand. Your local changes go in
.harbor/docker.override.yaml (or .harbor/docker.install.yaml), which Harbor merges with the
generated files.
You only need to touch the stubs in .harbor/stubs/ to change port bindings: keep them in sync
with vendor/empiricompany/harbor/resources/stubs/ when you upgrade Harbor.
Two practical recipes to get started.
Enable developer mode
# .harbor/docker.override.yaml, merged on top of the generated compose.yaml
services:
app:
environment:
MAGE_IS_DEVELOPER_MODE: "1"
Apply the change with ./vendor/bin/harbor up -d: Maho switches to developer mode, with the more
verbose logging and error handling you want while developing.
Add a service to the stack
You can declare a service yourself in the override file. With the profiles key it becomes
optional, and Harbor discovers and manages it like the built-in profiles. This example adds
phpRedisAdmin, the web UI for Redis (it also needs the redis profile to be active, so there is a
server to connect to):
# .harbor/docker.override.yaml
services:
# phpRedisAdmin: web UI for Redis (optional profile). Requires the
# "redis" profile to be active to have a Redis server to connect to.
phpredisadmin:
image: erikdubbelboer/phpredisadmin:latest
profiles: [phpredisadmin]
environment:
REDIS_1_HOST: "${HARBOR_REDIS_HOST:-redis}"
REDIS_1_PORT: "${HARBOR_REDIS_PORT:-6379}"
ports:
- "${HARBOR_PHPREDISADMIN_PORT:-8084}:80"
networks:
- harbor
Then enable the profiles and start:
./vendor/bin/harbor services add redis
./vendor/bin/harbor services add phpredisadmin
./vendor/bin/harbor up -d
The UI is on http://localhost:8084. If you drop the profiles key entirely, the service becomes
part of the default stack and starts on every up.
This is just the basics. The complete guide, with every command and option, is in the Harbor README: https://github.com/empiricompany/harbor.
VS Code and Dev Containers
Harbor generates the Dev Container configuration with a single command:
./vendor/bin/harbor devcontainer --force
It writes .devcontainer/devcontainer.json, pointing to ../.harbor/compose.yaml, opening the
app service and using /app as the workspace. With VS Code’s Dev Containers extension, when
you open the project you are asked whether to reopen it in the container. The configuration also
installs the Maho VS Code extension.
To make that extension use the container’s PHP, add this to .vscode/settings.json:
{
"maho.phpCommand": "./vendor/bin/harbor php"
}
That way every PHP command from the extension goes through Harbor and runs where Maho runs, without installing PHP on the host.
Debugging with Xdebug
Xdebug is already included in the app image: switch it on from .harbor/.env.
HARBOR_XDEBUG_MODE=debug
Xdebug connects back to the IDE on port 9003. To verify it is active:
./vendor/bin/harbor php -m | grep -i '^xdebug$'
./vendor/bin/harbor php -i | grep -E 'xdebug.mode|xdebug.client'
In VS Code, with the PHP Debug extension, the launch configuration maps /app to the project
root:
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": { "/app": "${workspaceFolder}" }
}
]
}
Start “Listen for Xdebug” (F5), set a breakpoint and run ./vendor/bin/harbor debug script.php, or
open a page in the browser. On a remote host the cleanest combination is VS Code Remote-SSH with
harbor devcontainer --force: the extension host runs on the server and port 9003 is reachable
with no extra tunnel.
Other Harbor features
-
Maho MCP server: Harbor starts it over stdio, so compatible AI clients can talk to the store. In the client configuration:
{ "mcpServers": { "maho-stdio": { "command": "./vendor/bin/harbor", "args": ["maho", "dev:mcp:start"] } } } -
Accessibility scan: install the Playwright runtime once with
./vendor/bin/harbor maho accessibility:install, then scan a page with./vendor/bin/harbor maho accessibility:scan https://localhost:8443/. -
Quality tools:
./vendor/bin/harbor test,./vendor/bin/harbor phpunitand./vendor/bin/harbor phpstanrun inside the container, likeharbor phpandharbor bin. -
Quick open:
./vendor/bin/harbor openopens the UI;open adminandopen mailpitopen the matching pages. -
Developer experience:
empiricompany/maho-ignitionadds pretty error pages, with optional generated solutions, andcomposer require intervention/image-driver-vipsenables high-performance image processing. Both are optional.
Quick troubleshooting
./vendor/bin/harbor config # check the effective Compose configuration
./vendor/bin/harbor doctor # validate Docker, Compose and the stack
./vendor/bin/harbor ps # see what is running
./vendor/bin/harbor logs app # PHP errors and Caddy access log
./vendor/bin/harbor logs cron # scheduled jobs
Always run the launcher from the directory that contains vendor/, .harbor/ and the Maho
project files.
⚓ Last ship’s log. The harbor is ready and so are the docks. Hoist the sail, power it up and let Harbor handle the logistics: your ideas have only the sea left to explore. Fair winds!
Editor's note.Text prepared with the help of AI and checked against the source before publication. It can still contain mistakes: if you find one, report it.Read the manifesto
Does your store still sell?
Tell us where you are starting from: we reply with a first assessment, no strings attached.
Ask for the store assessment