A Maho extension is an ordinary Composer package, but developing it inside the store that uses it
usually pushes you toward one of two bad options: working straight in vendor/, where the next
composer update wipes your changes, or copying files back and forth by hand. localdev/ closes
both. Every package lives in a folder of its own, with a git repository separate from the root, and
Composer symlinks it into vendor/. The code you write is already the code the store runs, with no
copies and no reinstalling the project after every edit.
# without localdev: you edit in vendor, and the next update wipes your changes
vim vendor/acme/maho-example-shipping/Model/Shipping.php
# with localdev: you edit in localdev/, and Composer symlinks it into vendor/
vim localdev/maho-example-shipping/Model/Shipping.php
🔧 Bench note. The workshop is yours: the toolbox (
localdev/) sits next to the warehouse (vendor/), but the two never mix. You can plane, glue and start over without touching the goods already out on the shelf.
What you need
- A Maho project with a working environment. Our examples use Harbor, the open source Docker Compose launcher by MageMEGA, inspired by Laravel Sail, described in Local Maho development with Harbor: if you do not know it yet, start from that article and come back here.
- Docker with the Compose v2 plugin, because every command goes through Harbor.
- Git, because every package in
localdev/is a repository of its own, with its own remote and its own history.
Quick start: from package to store
1. Declare the path repository
Tell Composer that the folders under localdev/ are a source of packages. Open the root
composer.json and add (or check) this block:
{
"repositories": [
{
"type": "path",
"url": "./localdev/*",
"options": {
"symlink": true
},
"canonical": true
}
]
}
type: "path"means the source is a local folder, not Packagist.url: "./localdev/*"treats every subfolder as a package.symlink: truemakesvendor/<vendor>/<package>point straight at the folder inlocaldev/.canonical: truemakes this source win over Packagist for a package with the same name.
2. Put the package in localdev/
mkdir -p localdev
git clone [email protected]:acme/maho-example-shipping.git localdev/maho-example-shipping
If you start from scratch, scaffold the skeleton from the empiricompany/module-template (a fork of
MahoCommerce/module-template) and initialise the repository right there. Either way the package has
its own .git, its own remotes and its own history: the root does not treat it as a versioned
subfolder.
3. Require it with @dev
A path repository does not publish tagged versions, so the package resolves as a development
version. Install it with the @dev constraint:
./vendor/bin/harbor composer require acme/maho-example-shipping:@dev
Alternatively write the dependency by hand and update just that one:
"require": {
"acme/maho-example-shipping": "@dev"
}
./vendor/bin/harbor composer update acme/maho-example-shipping
4. Install the package dependencies and run the gates
The package has a composer.json of its own, with its own dev dependencies and its own scripts. Drive
it with --working-dir, without entering the folder:
# first time: install the dev dependencies inside the package
./vendor/bin/harbor composer --working-dir=localdev/maho-example-shipping install
# every gate of the package (test, lint, cs, phpstan, rector) before pushing
./vendor/bin/harbor composer --working-dir=localdev/maho-example-shipping check
--working-dir points Composer at the package folder, while harbor is always launched from the
project root. check is the alias defined in the package composer.json: it holds every check the
repository runs in CI.
5. Apply the module to the store
./vendor/bin/harbor composer update # root: wire up and refresh the package
./vendor/bin/harbor maho migrate # run the module installers
./vendor/bin/harbor maho cache:flush
The store now sees the extension. migrate applies the schema and data the module declares;
cache:flush is mandatory after renaming blocks, layout handles or config aliases.
🔧 Bench note. Between the bench and the shopfront sits a root
composer updatethat wires the package to the store. It is the moment the workbench faces the shop: from here on, every weld inlocaldev/is already visible, with nothing to copy.
How it works
The key point is that vendor/<vendor>/<package> is not a copy of the package: it is a link.
ls -l vendor/acme/maho-example-shipping
# lrwxrwxrwx ... vendor/acme/maho-example-shipping -> ../../localdev/maho-example-shipping
Three practical consequences follow:
- Edit and see it at once. Maho’s bootstrap loads classes from the path inside
vendor/, which is the link: a change inlocaldev/is already running, with nothing to reinstall. - A separate repository.
localdev/collects repositories of their own, while the root stays your project. You can version, branch and push the package without touching the store, and the other way around. - No vendor pollution. You never work inside
vendor/and never risk losing your changes on the next update.
The @dev constraint is not a quirk: it is needed because a path repository exposes no tagged
versions. The package resolves as dev, and its changes are re-read on every composer update.
Day to day
Editing the module code. Work directly in localdev/<package>: PHP, etc/config.xml, layout,
templates and the code under app/code/ are read live from the symlink. After renaming blocks,
aliases or layout handles, flush the cache:
./vendor/bin/harbor maho cache:flush
Running the package gates. They are the same commands as in step 4, and the right sequence before
pushing is install the first time, then check:
./vendor/bin/harbor composer --working-dir=localdev/maho-example-shipping check
Avoid harbor composer exec --working-dir: it fails with chdir(): No such file or directory. Run
the binary from a script or directly.
Wiring the store to the package. After adding or renaming a package, or after changing its assets:
./vendor/bin/harbor composer update
Checking the result.
./vendor/bin/harbor maho migrate
./vendor/bin/harbor maho db:query "SELECT * FROM core_resource WHERE code LIKE '%example%'"
./vendor/bin/harbor maho cache:flush
If the package ships CLI commands, they show up in Maho’s list:
./vendor/bin/harbor maho list | grep example
The exception: assets published into public/
Everything above holds for the PHP code and for the files resolved live from the symlink. Assets
are the exception. The maho-composer-plugin copies the public/, skin/ and js/ folders of
every installed module into the project’s public/ tree: here there is no symlink, it is a real
copy.
Consequences:
- Editing a file under
localdev/<package>/public/does not propagate on its own. Re-runcomposer updatefrom the root (just the one package is enough) to republish it. - Publication only copies, it never deletes: if you rename an asset folder, the old copy stays in
public/and must be removed by hand.
# a file changed under the package public/ is not live until you republish it
./vendor/bin/harbor composer update acme/maho-example-shipping
This applies to skin, JavaScript and any asset the module ships. Themes have a path of their own, which saves you from republishing by hand on every build.
Themes: copying back with --pack
When the package is a theme, the asset build follows its own flow, because the files must be
developed in the published copy and then brought back into the repository. The theme package
re-registers the core dev:frontend:theme:build command and adds the --pack option, which copies
the files from the project into the package (with prune) after the build. It comes from the utility
repository empiricompany/maho-theme-kit.
# develop the assets in the project's published copy
./vendor/bin/harbor maho dev:frontend:theme:build --theme <pkg>/<theme> --watch
# or rebuild and copy back into the package on every change
./vendor/bin/harbor maho dev:frontend:theme:build --theme <pkg>/<theme> --watch --pack
# minified build, before committing
./vendor/bin/harbor maho dev:frontend:theme:build --theme <pkg>/<theme> --pack
Order matters: composer install/update overwrites the asset copies in the project, so run
build --pack before Composer, or do not run Composer while you have edits not yet brought back
into the package. --pack skips packages installed as a copy in vendor/ (a dist, not a development
checkout): bringing files back there would be pointless.
🔧 Bench note. The theme is the furniture you fit into the shopfront: you carve it in the installed copy and, once it is done,
--packreturns it to the mould. Plain modules need no special tool: acomposer updatethat puts the changed assets back on display is enough.
Gates and release
Before pushing, the full sequence inside the package:
./vendor/bin/harbor composer --working-dir=localdev/maho-example-shipping install
./vendor/bin/harbor composer --working-dir=localdev/maho-example-shipping check
check runs the scripts declared in the package composer.json: typically test, lint, cs
(php-cs-fixer), phpstan and rector. The same checks run on GitHub Actions together with
composer validate --strict, the syntax checks and the line-ending gate. Once they are green:
git -C localdev/maho-example-shipping add -A
git -C localdev/maho-example-shipping commit -m "Add example shipping extension"
git -C localdev/maho-example-shipping push
A version tag fires the release when the package has its dedicated workflow. The gates prove style and types, not behaviour: after pushing, try the flow by hand in the store (checkout, customer area, admin) before calling it done.
🔧 Last bench note. The bench is tidy, the tools are back in place and the shopfront is one
composer updateaway. From here you can publish the module, open another one next to it and keep everything separate in the same workshop. Happy building!
Troubleshooting
| Symptom | Check |
|---|---|
| Composer does not see the package | the repositories block with url: "./localdev/*" in the root, then composer update |
| The installed package is not a symlink | options.symlink: true, and that no earlier copy sits in vendor/ |
| PHP changes do not show up | cache:flush; for block and alias renames the layout/config cache still serves stale XML |
| A changed asset does not update | republish with composer update from the root: it is a copy, not a symlink |
| An orphaned asset folder stays behind | publication never deletes: remove it by hand from public/ |
harbor composer exec --working-dir fails |
do not use it: run the binary through a script |
| The gates pass but the store breaks | the gates do not test behaviour: run the flow by hand |
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