Skip to content
A large ship is being built in a dry dock. Workers are standing around the ship, and there is a lot of fog in the air.

Getting this site ready for Craft 6

To get ready for Craft 6, we moved the logic out of our Twig templates and into PHP. The site got faster too: some pages now do 90% less work to load.

Web · October 27, 2026 · 4 min read

This site runs on Craft CMS 5. The next version, Craft 6, is being rebuilt on Laravel,1 a different PHP framework from Yii, the one Craft runs on today. Templates are still written in Twig, so most of them will carry over. The parts that won't carry over cleanly are the bits of template code that reach into Craft's internals, like the craft.app shortcut and the template variables many plugins add. The more of that a site's templates contain, the more work the upgrade will be.

There's no deadline forcing this. Craft 5 will be supported for five years after Craft 6 ships,2 and Craft 6 can run older code through a compatibility layer,3 so most Craft 5 sites can upgrade on their own schedule. The full release is expected sometime between late 2026 and early 2027.4 We did it now because it was cheaper to do once, while we were already working on the site.

We build most of our sites on Craft, so we spent about a week getting this one ready before the upgrade instead of after it. Here's what we changed and why.

Where the logic lived

In a typical Craft 5 site, templates do a lot of the work. Here's a grid of recent projects, written the way most Craft templates are:

templates/_modules/grid.twig (before) Twig
{% set projects = craft.entries()
    .section('workProjects')
    .image(':notempty:')
    .orderBy('postDate desc')
    .limit(module.gridItems ?: 6)
    .with(['image', 'disciplines'])
    .all() %}

{% for project in projects %}
    {% set image = project.image.one() %}
    <a href="{{ project.url }}">
        <img src="{{ image.getUrl({ width: 800, height: 500, mode: 'crop' }) }}"
             alt="{{ image.alt ?: project.title }}">
        <h3>{{ project.title }}</h3>
    </a>
{% endfor %}

It works, and it's quick to write. But the query, the default count of six, the image size and the alt-text fallback all live in the markup. Spread that across thirty templates, and an upgrade means reading every one of them to find what will break.

What we moved

We moved data access out of the templates and into PHP. The site has one custom module with a handful of services: one each for work, the blog, the page builder and media, and one for site-wide things like the head tags and navigation. Each service method returns a plain array, already shaped for the template that uses it.

The same grid now reads:

templates/_modules/grid.twig (after) Twig
{% for card in data.cards %}
    <a href="{{ card.url }}">
        <img src="{{ card.img }}" alt="{{ card.alt }}">
        <h3>{{ card.title }}</h3>
    </a>
{% endfor %}

The template no longer knows where the image comes from, what size it should be, or what to do when it has no alt text. It loops and prints. All of that moved into WorkService, simplified a little here:

modules/sitemodule/services/WorkService.php Twig
public function getGridData(Entry $module): array
{
    $limit = (int)$module->gridItems ?: self::DEFAULT_GRID_ITEMS;

    $projects = Entry::find()
        ->section('workProjects')
        ->image(':notempty:')
        ->orderBy('postDate desc')
        ->limit($limit)
        ->with(['image', 'disciplines'])
        ->all();

    return ['cards' => array_map(fn(Entry $project) => $this->card($project), $projects)];
}

private function card(Entry $project): array
{
    $image = $project->image->one();

    return [
        'url' => $project->url,
        'title' => $project->title,
        'img' => $image->getUrl(['width' => 800, 'height' => 500, 'mode' => 'crop']),
        'alt' => $image->alt ?: $project->title,
    ];
}

Small rules, like how a page title is formatted, how credits are grouped, or how long a post takes to read, live in helper classes that never call Craft, so we can test them without booting it.

Keeping it that way

Rules like this slip. So a test reads every template and fails if it finds an element query, a .one() or .all(), craft.app, or a plugin's template variable. When Craft 6 arrives, the work should be in the PHP services, in one place and with tests around it, instead of scattered through the templates.

What we took out

Moving the logic was a good time to look at everything else the site depends on. A plugin you no longer use still needs updating, still runs on every request, and still has to make the jump to Craft 6, whether or not its developer gets there. A few of ours had been installed for one feature we'd since replaced, and one was a paid license we were renewing out of habit. We kept the plugins that do something we'd otherwise have to build, and took out the rest.

The front end got the same look. Small JavaScript libraries are easy to add: a line in a package file, or a script tag pointing at a CDN, and you have a working dropdown. But every one is code someone else maintains and another download for the browser. One pulled from a CDN also makes the page depend on someone else's server staying up. Most of what we used them for, like opening a menu, sending a form or enlarging an image, now takes a few dozen lines of plain JavaScript, so that's what we wrote. We kept GSAP and three.js. The animation and the 3D work on the homepage would take far more than a few dozen lines to replace.

What it cost, and what it bought

There's a cost. The code is now spread across more files, and changing what a page shows means editing a PHP service, where before you only had to touch the Twig template. For a five-page brochure site, we don't think that trade is worth it.

For this site it paid off before Craft 6 is even out. The homepage went from 68 database queries to 27, the work listing from 25 to 5, and a project page from 120 to 12. Most of that came from loading what a page needs in one pass, where the templates had been asking for it piece by piece.

The page builder

The same split made the page builder easier to grow. Every module gets two things: its own text fields, and an array of data its service prepared. Adding a module means one template and one method. That's how the project pages here went from a fixed layout to modules we arrange per project: a gallery, credits, a color palette. And it's how this post got its code blocks.

If your site runs on Craft 5 and you need help migrating to Craft 6, we're here for you.

Notes

  1. Note 1:

    Pixel & Tonic announced the move to Laravel at Dot All 2025. Craft is moving to Laravel, Craft CMS blog.

    ↩
  2. Note 2:

    Craft 5 is a long-term support release. Pixel & Tonic plans to support and maintain it for five years after Craft 6 is released. Same post: Craft is moving to Laravel.

    ↩
  3. Note 3:

    Craft 6 includes a Yii 2 adapter, a compatibility layer so plugins and modules written for Craft 5 keep working while they're updated. Craft CMS is moving to Laravel, Laravel News.

    ↩
  4. Note 4:

    At Dot All 2025, Pixel & Tonic expected a Craft 6 beta in the third quarter of 2026 and a full release in the fourth. A July 2026 write-up puts general availability around March 2027; we haven't seen an official update. Craft CMS 6 alpha: what it means for your website, Engage.

    ↩