Back to Blog
Craft CMS PHP Modules

Building Custom Modules in Craft CMS (And When to Skip the Plugin)

· 12 min read

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.

Module or Plugin? Will other sites use this? Yes Plugin No Does it need CP settings UI? Yes Plugin No Does it need DB migrations? Yes Plugin (probably) No Module Common module use cases: Event listeners · Twig extensions · Console commands · API integrations · Custom validation Entry slug generation · Webhook dispatchers · Search index customization

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:

Terminal
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 like acme-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:

your-craft-project/
├── config/
│ └── app.php ← updated to register your module
└── modules/
    └── sitemodule/
        └── SiteModule.php

The generated module class looks something like this:

modules/sitemodule/SiteModule.php
<?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:

config/app.php
<?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.

modules/sitemodule/SiteModule.php
<?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:

modules/sitemodule/twigextensions/ReadingTimeExtension.php
<?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:

modules/sitemodule/SiteModule.php (updated init)
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:

Twig template
<span class="meta">{{ entry.body|readingTime }}</span>
{# Output: "4 min read" #}
When you add a Twig extension, you're making it available everywhere. Keep the filter/function names specific enough that they won't collide with anything else. Prefixing with your project name is a good habit if you're worried about conflicts (like |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.

modules/sitemodule/console/controllers/CleanupController.php
<?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:

Terminal
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:

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:

modules/
└── sitemodule/
    ├── SiteModule.php
    ├── services/SyncService.php
    ├── twigextensions/
    │ └── ReadingTimeExtension.php
    └── console/
        └── controllers/
            └── CleanupController.php
modules/sitemodule/services/SyncService.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:

modules/sitemodule/SiteModule.php
<?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->sync from 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
Doing HTTP calls inside 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:

modules/sitemodule/jobs/SyncEntryJob.php
<?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:

In the event handler
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 like registerEventHandlers() that init() 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(), and Craft::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_SAVE fires 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.json has 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:

composer.json (autoload section)
{
  "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.