Skip to content
Craft CMS

Craft CMS best practices: faster, clearer, easier to maintain

Luremo

Craft CMS studio

A developer arranging content cards beside a laptop on a dark desk.
Planning a content model before building the templates. Image: AI-generated.

A good Craft site starts with a clear content model. Then you make templates efficient, cache where it helps, and keep the installation secure. Seven practical choices for Craft CMS 5.

Design the content model around the editor's work

Before writing a template, find out which information editors enter repeatedly. A case study might have a client, services, results and images. Give information that appears elsewhere its own entry and connect it with relationships. The same service can then appear on a case study, a service page and an overview without maintaining three copies.

Use Matrix to control the order of page sections such as text, images and quotes. In Craft 5, those sections are nested entries. Create a new block type only when it needs genuinely different input or presentation; dozens of near-identical options make editing harder. Test the model with someone who will use it: can they add a case study, link a service and replace an image without instructions?

A content model is not a one-off technical exercise. Document field names, required fields and translation rules so the editing experience still makes sense a year later.

How we do this at Luremo: _patterns/matrix.twig selects a template for each Matrix type. _patterns/matrix/cards.twig fetches the card data and passes prepared values to card-collection.twig. That presentation partial does not need to query content itself.

Spot the N+1 query in a list

An overview with twelve articles and one image per article looks simple. But if the template fetches each related image separately, one query for the articles can lead to twelve more for the images. Add categories or authors and the number grows further. This is the N+1 query problem.

Craft 5 provides lazy eager loading. Add .eagerly() to the relationship query used inside the list. Craft can then fetch the images together when they are needed:

How we do this at Luremo: the blog index fetches up to twelve articles per page and preloads their images with .with(['image']). In the for loop that maps articles to cards, article.image.first() uses the preloaded relationship. That avoids an extra image query per article.

Twig · Craft 5
{% set articles = craft.entries().section('blog').limit(12).all() %}

{% for article in articles %}
    {% set image = article.image.eagerly().one() %}
    <article>
        <h2>{{ article.title }}</h2>
        {% if image %}
            {{ image.getImg() }}
        {% endif %}
    </article>
{% endfor %}

Choose between .eagerly() and .with() deliberately

.eagerly() works well in reusable partials: the code that uses a relationship declares that it may benefit from batch loading. If the main query already knows which relationships it will need, .with(['image']) is also a good choice. Some native relationships and image transforms that must be loaded up front still require .with(). These are tools, not methods to add blindly to every query.

The same applies to Matrix. If a list shows both nested blocks and their images, look at both layers. Then use the Debug Toolbar to check whether the query count and time actually improve. The nystudio107 article shows why measuring before and after is more useful than optimizing by instinct; consult the official documentation for current Craft 5 syntax.

How we do this at Luremo: _patterns/matrix/cards.twig uses block.cardItems.eagerly().all() for nested cards and card.image.eagerly().one() for their images. The blog index uses .with(['image']) instead, because the main query already knows that each card may need an image.

Cache the article page and refresh it after an edit

An article page may render several relationships, Matrix blocks and images. In this project, the Twig cache tag sits in the shared entry template. Its key combines entry.id with entry.dateUpdated.timestamp. When the article entry is edited and saved, the timestamp changes and Craft uses a new key. The next request rebuilds the article output.

The Matrix query runs inside the cache tag. This lets Craft track the nested elements used in the output and invalidate the fragment when that content changes. The simplified example below follows this site's setup: Craft has already loaded the entry, the blocks are eager-loaded, and a pattern renders the content. It does not query or cache the blog index.

Keep forms, CSRF tokens and user-specific output outside this tag. A custom cache key survives template code changes; deliberately change the key version or clear the cached output after such a change. Measure whether caching helps on your page.

How we do this at Luremo: _layouts/entry.twig uses this exact key for article content and fetches Matrix blocks inside the cache tag. Template caching is disabled in local development; the deployment script clears the template cache after craft up so Twig changes become visible immediately.

Twig · article cache
{% cache using key 'pages:' ~ entry.id ~ ':' ~ entry.dateUpdated.timestamp %}
    <h1>{{ entry.title }}</h1>
    {% set blocks = entry.matrix.eagerly().all() %}
    {% include '_patterns/matrix.twig' with {
        entry: entry,
        blocks: blocks,
    } only %}
{% endcache %}

Serve images at the size their placement needs

A large upload does not need to reach every card at its original size. Use image transforms for the crop and dimensions the design needs, and offer appropriate variants with srcset. Give an image useful alt text when it conveys information; purely decorative images can have empty alt text. Specify width and height where possible so the layout does not shift while the image loads.

The first request for a new transform may take extra work. Test a cold page and the background queue after a release. An image loading quickly in your browser after a warm cache visit does not tell the whole story.

How we do this at Luremo: _patterns/entry-image.twig chooses separate transforms for mobile, tablet and desktop. _patterns/image.twig supplies srcset, sizes, image dimensions and alt text; it also uses the asset focal point for the crop.

Treat configuration as part of the code

Craft stores sections, fields, entry types and other system settings in Project Config. Version the YAML alongside templates and composer.lock, and apply changes through Craft. Do not edit the generated YAML by hand. During deployment, php craft up applies migrations and pending configuration changes.

Keep passwords, keys and environment-specific URLs in environment variables. That lets you move the same code to staging and production without committing secrets to Git. Plan content changes separately from schema changes: Project Config does not move articles or uploads.

How we do this at Luremo: schema settings live in config/project/, while PHP dependencies are pinned in composer.lock. During deployment, .docker/provision.sh installs those locked dependencies before running php craft up --interactive=0.

Make security and maintenance routine

A release is not finished just because the home page works. Keep Craft, plugins, PHP and the server up to date; make and verify backups; give administrators only the access they need. For production, Craft recommends disabling devMode and allowAdminChanges, and exposing only the public directory as the webroot. Protect the control panel with HTTPS and keep secrets out of public reach. These are part of Craft's security guidance.

Decide who reviews a security update, who deploys it and how to roll back a failed release. After each change, check at least an article page, an overview, images, forms and the queue. That makes maintainability a process rather than a promise.

How we do this at Luremo: config/general.php enables devMode and allowAdminChanges only in development. Updates go through Composer and only public/ is the webroot. A separate container handles the queue rather than running it during a page request.