How to build a custom Magento 2.4.9 module with a plugin and a CLI command
Build a working Magento Open Source or Adobe Commerce 2.4.9 module step by step: register it, log Admin sign-ins with an after plugin, add a bin/magento command that counts products, then enable and troubleshoot it.
Updated 9 min read

In this article
Almost every Magento project needs a little custom code: a log entry when something important happens, a command that an operations person can run from a shell, or a small change to how a core class behaves. Magento Open Source and Adobe Commerce give you a supported way to do all three without editing core files: a custom module that uses dependency injection, plugins (interceptors) and the bin/magento command-line tool.
This tutorial builds one small, working module from scratch. By the end you will have a module called Acme_StoreTools that records every successful Admin sign-in in the application log and adds a bin/magento acme:catalog:count command that prints how many products your catalog holds. Each file is short, and each one maps to a pattern you will reuse in bigger projects.
The code targets Magento Open Source and Adobe Commerce 2.4.9, released on 12 May 2026. According to Adobe's system requirements page, 2.4.9 is tested with PHP 8.5; the 2.4.8 line supports PHP 8.4 and 8.3. The module uses constructor property promotion and readonly properties, so it needs PHP 8.1 or later and runs on every PHP version that 2.4.8 and 2.4.9 support.
What you need before you start
- A working Magento Open Source or Adobe Commerce 2.4.8 or 2.4.9 installation that you can break, such as a local Docker environment. Do not develop on production.
- Shell access as the file system owner. Adobe's documentation warns that all
bin/magentocommands must run as that user. - Developer mode is recommended while you work, because it shows errors and generates code on the fly.
- Basic PHP knowledge: namespaces, classes and constructors.
Here is the finished structure. Custom modules that are not installed through Composer live under app/code/<Vendor>/<Module>.
app/code/Acme/StoreTools/
├── Console/
│ └── Command/
│ └── ProductCountCommand.php
├── Plugin/
│ └── AdminLoginLogger.php
├── etc/
│ ├── adminhtml/
│ │ └── di.xml
│ ├── di.xml
│ └── module.xml
├── composer.json
└── registration.phpStep 1: register the module
Every component, whether a module, theme or language pack, must be registered through the ComponentRegistrar class in a file called registration.php in its root directory. The name follows the pattern VendorName_ModuleName. Adobe's documentation recommends avoiding Ui as a module name because the %Vendor%_Ui notation used in paths can cause problems.
<?php
declare(strict_types=1);
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Acme_StoreTools', __DIR__);Next, declare the module in etc/module.xml. The smallest valid file only needs the name attribute. The sequence element tells Magento to load Magento_Backend and Magento_Catalog first, because our code depends on classes from both. We leave out setup_version: Adobe's guide says to omit it when a module uses declarative schema or, as here, has no database tables at all.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Acme_StoreTools">
<sequence>
<module name="Magento_Backend"/>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>A composer.json file is optional for code in app/code, but adding one now makes it easy to move the module into its own Composer package later. The autoload section loads registration.php and maps the PHP namespace to the module root, as shown in Adobe's "Name a component" guide. Adjust the PHP constraint to match the versions you actually test.
{
"name": "acme/module-store-tools",
"description": "Admin sign-in logging and a catalog CLI command",
"type": "magento2-module",
"license": "proprietary",
"require": {
"php": "~8.4.0||~8.5.0"
},
"autoload": {
"files": [ "registration.php" ],
"psr-4": { "Acme\\StoreTools\\": "" }
}
}Step 2: log Admin sign-ins with an after plugin
A plugin, or interceptor, is a class that changes the behaviour of a public method by running code before, after or around the original call. Plugins are the preferred way to customise core behaviour because several modules can intercept the same method; Magento runs them in order of their sortOrder instead of letting one module overwrite another's class.
| Plugin type | Method name | Typical use |
|---|---|---|
| Before | beforeMethodName() | Change the arguments passed to the original method |
| After | afterMethodName() | Read or change the return value, or react once the call has finished |
| Around | aroundMethodName() | Run code on both sides of the call, or skip it; use sparingly |
Plugins have limits. According to Adobe's developer documentation they cannot intercept final methods or classes, non-public methods, static methods, constructors and destructors, virtual types, or objects created before Magento\Framework\Interception is bootstrapped. If the method you need falls into one of those groups, look for an event or a preference instead.
Our plugin watches Magento\Backend\Model\Auth::login(). This exact case appears in Adobe's plugin guide: login() returns nothing, so the after method receives null as $result, followed by the original arguments. An after method only has to declare the arguments it uses and any that come before them, so we declare $username and skip the password. Never log passwords, and check that storing Admin user names in logs fits your privacy policy.
<?php
declare(strict_types=1);
namespace Acme\StoreTools\Plugin;
use Magento\Backend\Model\Auth;
use Psr\Log\LoggerInterface;
class AdminLoginLogger
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
/**
* Runs after Auth::login() has completed without throwing an exception.
*
* @param Auth $subject
* @param mixed $result Auth::login() returns void, so this is null
* @param string $username
* @return mixed
*/
public function afterLogin(Auth $subject, $result, $username)
{
$this->logger->info('Admin user signed in.', ['username' => $username]);
return $result;
}
}Because the Admin sign-in only happens in the adminhtml area, declare the plugin in etc/adminhtml/di.xml rather than the global etc/di.xml. Area-specific configuration keeps the interceptor out of storefront and API requests.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Backend\Model\Auth">
<plugin name="acme_store_tools_admin_login_logger"
type="Acme\StoreTools\Plugin\AdminLoginLogger"
sortOrder="10"
disabled="false"/>
</type>
</config>The name must be unique among plugins on the same type, so prefix it with your vendor and module. The logger is injected through the constructor: Magento's object manager maps Psr\Log\LoggerInterface to its Monolog-based logger, so the message ends up in the application log under var/log/.

Step 3: add a bin/magento command
The bin/magento tool is built on the Symfony Console component, and modules can add their own commands. Adobe's guide recommends placing command classes in Console/Command and registering them through dependency injection. Each command needs a unique name; otherwise Symfony throws an error saying the command cannot have an empty name.
Our command counts products. It asks for Magento\Catalog\Model\ResourceModel\Product\CollectionFactory in the constructor. You do not write that factory: Magento generates factory classes automatically, which is why the setup:di:compile step matters in production mode.
<?php
declare(strict_types=1);
namespace Acme\StoreTools\Console\Command;
use Magento\Catalog\Model\Product\Attribute\Source\Status;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class ProductCountCommand extends Command
{
private const OPTION_ENABLED_ONLY = 'enabled-only';
public function __construct(
private readonly CollectionFactory $collectionFactory,
?string $name = null
) {
parent::__construct($name);
}
protected function configure(): void
{
$this->setName('acme:catalog:count');
$this->setDescription('Print the number of products in the catalog.');
$this->addOption(
self::OPTION_ENABLED_ONLY,
null,
InputOption::VALUE_NONE,
'Count only enabled products'
);
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$collection = $this->collectionFactory->create();
if ($input->getOption(self::OPTION_ENABLED_ONLY)) {
$collection->addAttributeToFilter('status', Status::STATUS_ENABLED);
}
$output->writeln(sprintf('<info>Products: %d</info>', $collection->getSize()));
return Command::SUCCESS;
}
}A few details are worth noting. The constructor passes the optional $name on to the Symfony parent constructor, which then calls configure(). The --enabled-only option is a flag (InputOption::VALUE_NONE), so it takes no value. getSize() runs a COUNT query rather than loading every product, which keeps the command fast on large catalogs. Returning Command::SUCCESS gives the shell an exit code of 0, which matters if you call the command from scripts or cron.
Now register the command by adding it to the commands array of Magento\Framework\Console\CommandList in the global etc/di.xml:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="acme_catalog_count" xsi:type="object">Acme\StoreTools\Console\Command\ProductCountCommand</item>
</argument>
</arguments>
</type>
</config>Step 4: enable the module and run it
From the Magento root, enable the module, run the setup upgrade so the module is recorded in app/etc/config.php, compile dependency injection, and clear the cache. The setup:di:compile step is essential in production mode, where generated classes such as interceptors and factories are not created on demand.
bin/magento module:enable Acme_StoreTools
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:clean
bin/magento module:status Acme_StoreToolsThe last command should report the module as enabled. Now try the new command:
bin/magento list acme
bin/magento acme:catalog:count
bin/magento acme:catalog:count --enabled-onlyThe first line lists every command in the acme namespace. The second prints the total number of products; the third counts only enabled products. To test the plugin, sign out of the Admin and sign in again, then look for the "Admin user signed in." line in the logs under var/log/.
Troubleshooting the most common problems
| Symptom | Likely cause | Fix |
|---|---|---|
Command "acme:catalog:count" is not defined | Module disabled, cache not cleared or di.xml typo | Check module:status, validate the XML, run cache:clean and setup:di:compile |
Class not found for CollectionFactory | Generated code missing in production mode | Run bin/magento setup:di:compile |
| Plugin never runs | Plugin declared in the wrong area, wrong type name, or method name typo | Confirm the class and method names; afterLogin must match login |
| Changes have no effect | Stale generated interceptors | Clear generated/code in development, then recompile |
If a command or plugin depends on a disabled module, Magento hides it: Adobe's documentation notes that commands for disabled modules are not displayed. That is why the sequence declaration and correct module dependencies matter.

Good habits for production modules
- Keep plugins small. Every plugin adds a layer to the call stack. Prefer after and before plugins; use around plugins only when you must decide whether the original method runs.
- Use strict types and typed constructors. They catch mistakes early and match the coding style of the current core.
- Return exit codes from commands. Automation needs to know when a command failed.
- Version the module with Composer. Moving from
app/codeto a private Composer package makes deployments repeatable. Note that Adobe's documentation states the Composer path repository type is not supported. - Re-test after every upgrade. Release 2.4.9 replaced Laminas MVC with a native MVC implementation and Zend_Cache with Symfony Cache, according to Adobe's release notes. Code that extends framework classes deserves a careful check on each minor release.
Next steps
You now have a registered module with an area-specific plugin and a custom CLI command, which covers the core extension patterns used across Magento development. Sensible follow-ups are adding a crontab.xml job that calls the same logic as your command, writing unit tests for the plugin, and packaging the module with its own composer.json version. If you run Adobe Commerce as a Cloud Service, read Adobe's App Builder comparison first: that product favours out-of-process extensions over in-process PHP modules.
Frequently asked questions
Where should a custom Magento module live?
For project-specific code, use
app/code/<Vendor>/<Module>. For code you reuse across projects, package the module with its owncomposer.jsonand install it through Composer.Do I need setup_version in module.xml?
No, not if the module uses declarative schema or has no database tables. Adobe's documentation says to omit
setup_versionin that case.Why does my plugin not run on a private method?
Plugins only intercept public methods. They also cannot target final or static methods, constructors, destructors or virtual types. Use an event observer or a preference instead.
Why must I run setup:di:compile?
In production mode Magento does not generate factories, proxies and interceptors on demand.
setup:di:compilecreates them, so new plugins and constructor dependencies work.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Register a component — Adobe Developer
- Name a component — Adobe Developer
- Plugins (interceptors) — Adobe Developer
- Create a custom command — Adobe Developer
- System requirements (Adobe Commerce) — Adobe Experience League
- Adobe Commerce 2.4.9 release notes — Adobe Experience League
- App Development Comparison — Adobe Developer
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Troubleshooting guideCodeIgniter
CodeIgniter 4 validation not working? 9 causes and fixes
Fix CodeIgniter 4 validation problems fast: wrong input source, lost errors after redirect, permit_empty, strict JSON rules, is_unique placeholders and more.
By Cloudsoft Zone

TutorialPHP
PHPStan tutorial: catch PHP bugs before production with levels
Install PHPStan 2.3, choose a rule level, adopt a baseline for legacy code and fail CI only on new errors, with real output from a worked PHP 8.5 example.
By Cloudsoft Zone

Resource guideResources
Cron syntax cheat sheet: crontab, GitHub Actions, Kubernetes
Cron syntax with tested expressions, the day-of-week trap, and how time zones and limits differ in crontab, GitHub Actions, Kubernetes and Laravel in 2026.
By Cloudsoft Zone
