Building Custom Modules in Craft CMS (And When to Skip the Plugin)
Every Craft project I work on ends up with at least one custom module. Sometimes it's a small thing, like auto-generating a slug from two fields. Sometimes it's more involved, like syncing entries to an external API whenever they're saved. Either way, a module is almost always the right call for project-specific logic.
But I see a lot of developers reach for a full plugin when a module would be simpler and faster. So let's talk about the difference, and then I'll walk you through building a real module from scratch.
Module vs. Plugin: When to Use Which
This confuses a lot of people, but the rule is actually pretty simple:
- Module: Logic that's specific to this one project. It lives in the project repo. It doesn't need a control panel settings page, and you're never going to install it on another site.
- Plugin: Something reusable across projects. Has its own settings page in the control panel. You'd distribute it through Composer or the Plugin Store.
Under the hood, modules and plugins are almost identical. They're both Yii 2 modules. A Craft plugin is just a module with some extra scaffolding for installation, settings, and marketplace distribution. If you can build a module, you can build a plugin. The module is just less ceremony.
There are edge cases, sure. You can technically add database migrations to a module. But if you find yourself needing settings UI, migrations, and installable/uninstallable behavior, just make it a plugin. For everything else, a module is less overhead.
Scaffolding a Module
Craft has a built-in generator that sets everything up for you. Run this from your project root:
php craft make module
It'll ask you a few questions. I usually go with something like this:
- Module ID:
site-module(or something project-specific likeacme-module) - Module path:
modules/sitemodule
The generator creates the module class file and updates your config/app.php to bootstrap it. Here's what the file structure looks like after running it:
├── config/
│ └── app.php ← updated to register your module
└── modules/
└── sitemodule/
└── SiteModule.php
The generated module class looks something like this:
<?php
namespace modules\sitemodule;
use Craft;
use yii\base\Module;
class SiteModule extends Module
{
public function init(): void
{
Craft::setAlias('@modules/sitemodule', __DIR__);
// Set the controllerNamespace for console commands
if (Craft::$app->request->isConsoleRequest) {
$this->controllerNamespace = 'modules\\sitemodule\\console\\controllers';
}
parent::init();
// Your custom initialization code goes here
}
}
And the config registration:
<?php
use craft\helpers\App;
return [
'id' => App::env('CRAFT_APP_ID') ?: 'CraftCMS',
'modules' => [
'site-module' => \modules\sitemodule\SiteModule::class,
],
'bootstrap' => [
'site-module',
],
];
That's all the boilerplate you need. Everything from here is just adding functionality to the init() method (or organizing it into separate service classes).
Example 1: Auto-Generate Slugs from Multiple Fields
Let's say you have a "Projects" section where the slug should be a combination of the client name and project title. Content editors keep forgetting to set it manually, so you want to automate it.
<?php
namespace modules\sitemodule;
use Craft;
use craft\elements\Entry;
use craft\events\ModelEvent;
use craft\helpers\StringHelper;
use yii\base\Event;
use yii\base\Module;
class SiteModule extends Module
{
public function init(): void
{
Craft::setAlias('@modules/sitemodule', __DIR__);
parent::init();
Event::on(
Entry::class,
Entry::EVENT_BEFORE_SAVE,
function (ModelEvent $event) {
/** @var Entry $entry */
$entry = $event->sender;
// Only apply to the "projects" section
if ($entry->section?->handle !== 'projects') {
return;
}
// Build slug from client name + project title
$client = $entry->getFieldValue('clientName') ?? '';
$title = $entry->title ?? '';
if ($client && $title) {
$entry->slug = StringHelper::slugify(
"$client $title"
);
}
}
);
}
}
That's the whole thing. Every time a project entry is saved, the slug gets built from the client name and title fields. The StringHelper::slugify() method handles lowercasing, replacing spaces with hyphens, and stripping special characters.
Example 2: Adding a Custom Twig Extension
Sometimes you need a Twig filter or function that doesn't exist in Craft's built-in set. Maybe you want a |readingTime filter that estimates how long a blog post takes to read.
First, create the Twig extension class:
<?php
namespace modules\sitemodule\twigextensions;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class ReadingTimeExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('readingTime', [$this, 'readingTime']),
];
}
public function readingTime(string $content): string
{
// Strip HTML tags, count words
$text = strip_tags($content);
$wordCount = str_word_count($text);
// Average reading speed: 200 words per minute
$minutes = max(1, (int) ceil($wordCount / 200));
return "$minutes min read";
}
}
Then register it in your module:
public function init(): void
{
Craft::setAlias('@modules/sitemodule', __DIR__);
parent::init();
// Register Twig extension
Craft::$app->view->registerTwigExtension(
new \modules\sitemodule\twigextensions\ReadingTimeExtension()
);
}
Now you can use it in any template:
<span class="meta">{{ entry.body|readingTime }}</span>
{# Output: "4 min read" #}
|acmeReadingTime), but for most projects this isn't really an issue.
Example 3: Console Commands
Custom console commands are incredibly useful for maintenance tasks, data imports, and anything you want to run on a schedule via cron. Let's build one that cleans up draft entries older than 90 days.
<?php
namespace modules\sitemodule\console\controllers;
use Craft;
use craft\console\Controller;
use craft\elements\Entry;
use yii\console\ExitCode;
class CleanupController extends Controller
{
/**
* Deletes draft entries older than 90 days.
*/
public function actionStale(): int
{
$cutoff = (new \DateTime())->modify('-90 days');
$drafts = Entry::find()
->drafts(true)
->dateUpdated('< ' . $cutoff->format('Y-m-d'))
->all();
if (empty($drafts)) {
$this->stdout("No stale drafts found.\n");
return ExitCode::OK;
}
$count = count($drafts);
$this->stdout("Found $count stale drafts. Deleting...\n");
foreach ($drafts as $draft) {
Craft::$app->elements->deleteElement($draft, true);
}
$this->stdout("Done. Deleted $count drafts.\n");
return ExitCode::OK;
}
}
Run it from the terminal like this:
php craft site-module/cleanup/stale
The command name follows the pattern {module-id}/{controller}/{action}. So site-module is our module ID, cleanup is the controller name (from CleanupController), and stale is the action method (from actionStale).
Want to run it on a schedule? Add it to your crontab:
# Clean up stale drafts every Sunday at 3am
0 3 * * 0 cd /path/to/project && php craft site-module/cleanup/stale
Example 4: Syncing Entries to an External API
Here's a more real-world example. Let's say you need to push entry data to an external service whenever a blog post is published. Maybe it's a newsletter platform, a search index, or a headless CMS cache.
I like to keep this kind of logic in a separate service class rather than cramming everything into the module's init() method. Here's how I'd structure it:
└── sitemodule/
├── SiteModule.php
├── services/SyncService.php
├── twigextensions/
│ └── ReadingTimeExtension.php
└── console/
└── controllers/
└── CleanupController.php
<?php
namespace modules\sitemodule\services;
use Craft;
use craft\elements\Entry;
use yii\base\Component;
class SyncService extends Component
{
public function syncEntry(Entry $entry): bool
{
$payload = [
'id' => $entry->id,
'title' => $entry->title,
'url' => $entry->url,
'body' => (string) $entry->getFieldValue('body'),
'datePublished' => $entry->postDate?->format('c'),
];
try {
$client = Craft::createGuzzleClient();
$response = $client->post('https://api.example.com/entries', [
'json' => $payload,
'headers' => [
'Authorization' => 'Bearer ' . getenv('SYNC_API_KEY'),
],
'timeout' => 10,
]);
return $response->getStatusCode() === 200;
} catch (\Throwable $e) {
Craft::error(
"Failed to sync entry {$entry->id}: {$e->getMessage()}",
__METHOD__
);
return false;
}
}
}
Then register the service and hook into the entry save event:
<?php
namespace modules\sitemodule;
use Craft;
use craft\elements\Entry;
use craft\events\ModelEvent;
use modules\sitemodule\services\SyncService;
use yii\base\Event;
use yii\base\Module;
class SiteModule extends Module
{
public function init(): void
{
Craft::setAlias('@modules/sitemodule', __DIR__);
// Register services
$this->setComponents([
'sync' => SyncService::class,
]);
parent::init();
// Sync blog entries after they're saved
Event::on(
Entry::class,
Entry::EVENT_AFTER_SAVE,
function (ModelEvent $event) {
/** @var Entry $entry */
$entry = $event->sender;
// Only sync published blog entries
if (
$entry->section?->handle === 'blog'
&& $entry->enabled
&& !$entry->getIsDraft()
&& !$entry->getIsRevision()
) {
$this->sync->syncEntry($entry);
}
}
);
}
}
A few things worth noting about this pattern:
- The service is registered through
setComponents(), so Craft handles instantiation and you can access it as$this->syncfrom within the module - We check for drafts and revisions to avoid syncing on every autosave
- The try/catch in the service ensures a failed API call doesn't break the entry save
- Errors go to Craft's log system so you can debug them through the control panel or log files
EVENT_AFTER_SAVE means the content editor waits for the API request to finish before they see the save confirmation. For slow APIs, consider using Craft's queue system instead. Push a job onto the queue in the event handler, and let the queue worker handle the actual HTTP call in the background.
Using the Queue for Async Work
Since I just mentioned it, here's what the queue approach looks like. Create a job class:
<?php
namespace modules\sitemodule\jobs;
use Craft;
use craft\elements\Entry;
use craft\queue\BaseJob;
use modules\sitemodule\SiteModule;
class SyncEntryJob extends BaseJob
{
public int $entryId;
public function execute($queue): void
{
$entry = Entry::find()->id($this->entryId)->one();
if (!$entry) {
return;
}
SiteModule::getInstance()->sync->syncEntry($entry);
}
protected function defaultDescription(): ?string
{
return "Syncing entry #$this->entryId to external API";
}
}
Then push the job in your event handler instead of calling the service directly:
use modules\sitemodule\jobs\SyncEntryJob;
// Instead of: $this->sync->syncEntry($entry);
Craft::$app->queue->push(new SyncEntryJob([
'entryId' => $entry->id,
]));
The entry save returns immediately and the API call happens in the background. You can monitor it in the Craft control panel under Utilities > Queue Manager.
Tips from the Trenches
- Keep your
init()method clean. If you've got more than 3 or 4 event handlers, move them into a separate method likeregisterEventHandlers()thatinit()calls. Readability matters. - Use service classes. Don't put business logic directly in event handlers. Extract it into services. This makes it testable and reusable from console commands.
- Log generously. Use
Craft::info(),Craft::warning(), andCraft::error(). When something goes wrong in production at 2am, you'll be glad the logs are there. - Check for drafts and revisions. The
EVENT_AFTER_SAVEfires for autosave drafts too. If you don't guard against this, your event handler will fire constantly while someone is editing. - Don't forget the namespace. Make sure your
composer.jsonhas the PSR-4 autoload mapping for your module namespace. The generator usually handles this, but I've debugged "class not found" errors more than once because this was missing.
Check that your composer.json includes:
{
"autoload": {
"psr-4": {
"modules\\sitemodule\\": "modules/sitemodule/"
}
}
}
And run composer dump-autoload after adding it.
Modules are one of those things where once you build your first one, you start seeing uses for them everywhere. They're the cleanest way to add project-specific behavior to Craft without the overhead of a full plugin. Start with something small, like the slug generator, and build from there.
If you've got a project that needs custom Craft development, whether it's a module, a plugin, or something more complex, let's talk about it.