Vai al contenuto
Home / Blog /Tutorial / Developing a Maho extension with localdev

Developing a Maho extension with localdev

Turn the localdev/ folder into your build bench: every package keeps its own git repository, is symlinked into vendor/, and the CI gates run with a single Harbor command.

Leggi in italiano

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: true makes vendor/<vendor>/<package> point straight at the folder in localdev/.
  • canonical: true makes 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 update that wires the package to the store. It is the moment the workbench faces the shop: from here on, every weld in localdev/ 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 in localdev/ 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-run composer update from 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, --pack returns it to the mould. Plain modules need no special tool: a composer update that 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 update away. 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