craftcms/cms
Craft CMS is a flexible, self-hosted PHP content management system for building custom digital experiences. It offers an intuitive control panel, Twig templating, an auto-generated GraphQL API, built-in ecommerce, and a robust plugin ecosystem.
First, the mental model shift. craftcms/cms is not a package you composer require into a Laravel app. It is the application β a Yii2-based monolith that owns routing, DI, migrations, the admin UI, and the ORM. There is no Artisan, no Eloquent, no Blade, no app/Models. Everything you know about Composer, .env, and PSR-4 still applies; almost nothing else does. The correct entry point is the starter project:
composer create-project craftcms/craft my-site
cd my-site
php craft install # or run the web-based setup wizard
php craft up # applies migrations + project config
Where to look first, in this order:
config/general.php β environment behavior (devMode, allowAdminChanges, securityKey).config/project/*.yaml β the project config. This is Craft's schema-as-code: sections, fields, entry types, GraphQL schemas. It is the single most important artifact in the repo.templates/ β Twig front end. templates/index.twig is the home page; routing is file-based plus config/routes.php.modules/ β where your bespoke PHP goes.web/index.php β the only web-exposed PHP file..env / .env.example β DB creds, CRAFT_ENVIRONMENT, CRAFT_SECURITY_KEY.First use case (30 minutes): model a blog and render it.
blog, one entry type article, with a body (Redactor/CKEditor) and heroImage (Assets) field.templates/blog/index.twig:{% for entry in craft.entries.section('blog').limit(10).all() %}
<a href="{{ entry.url }}">{{ entry.title }}</a>
{{ entry.heroImage.one().url ?? '' }}
{% endfor %}
That loop is the whole value proposition: content modeling in the CP, zero PHP for the read path. Commit config/project/ immediately after β that diff is your deploy.
Console is your daily driver:
php craft up # migrate + apply project config (deploy step)
php craft project-config/apply # sync YAML without migrations
php craft resave/entries --section=blog --set=title
php craft queue/listen # required for transforms, resaves, mail
php craft clear-caches/all
Project config as the deployment contract. Treat config/project/ like Laravel migrations: never hand-edit YAML in prod, always promote from a lower env. The workflow is local CP changes β git diff config/project β commit β CI runs php craft up. Enable allowAdminChanges => false in staging/production so nobody can drift the schema through the UI.
Multi-environment config without a config library. Craft 5.9 supports env-var interpolation inline in settings, which removes most switch (Craft::$app->env) blocks:
// config/general.php
'aliases' => [
'@assetBaseUrl' => 'https://cdn.example.com/$CDN_ENV/',
],
'devMode' => App::env('CRAFT_ENVIRONMENT') === 'dev',
CRAFT_SITE and CRAFT_SITE_UPPER are resolved at runtime, so per-site settings no longer need duplicated blocks.
Element queries are your repository layer. Learn these four chain methods and you've replaced most of Eloquent:
{% set posts = craft.entries
.section('blog')
.status(null) {# include disabled/drafts #}
.with(['author', 'heroImage']) {# eager-load, kills N+1 #}
.orderBy('postDate DESC')
.limit(12)
.all() %}
Always .with() for relational fields rendered in a loop; this is the number one performance lever in Craft. Use .cache() or {% cache %} for expensive nav/footer queries.
Headless / API work β GraphQL before custom controllers. Create a schema in the CP, generate a token, and POST to /api:
{ entries(section: "blog", limit: 10) { title uri ... on blog_article_Entry { body } } }
Per 5.9, @parseRefs and @transform are now opt-in per schema (they closed an IDOR). If you're upgrading, audit every schema for expected transform behavior before shipping. For preview requests, pass X-Craft-Preview-Token with the x-craft-preview value from the preview URL.
Bespoke logic belongs in a module, not templates. Scaffold modules/site/, register it in config/app.php, and hook events:
use craft\elements\Entry;
use craft\events\ModelEvent;
use yii\base\Event;
Event::on(Entry::class, Entry::EVENT_BEFORE_SAVE, function (ModelEvent $e) {
/** @var Entry $entry */
if ($entry->section->handle === 'blog' && !$entry->authorId) {
$entry->authorId = Craft::$app->getUser()->getId();
}
});
Saving programmatically looks like this β and the boolean is not enough:
if (!Craft::$app->getElements()->saveElement($entry, true, true)) {
throw new \Exception(json_encode($entry->getErrors()));
}
Console commands and jobs for anything slow. Image transforms, resave, and external API syncs all go through the queue; write a craft\base\Job and push it, then run php craft queue/listen locally and a supervised worker in prod.
Data seeding / content migrations. Use php craft entrify <type> to generate a craft\db\Migration that creates sample entries, so php craft up gives every dev a populated site. entrify in 5.9 no longer requires a group handle and auto-assigns new sections to Categories/Tags pages.
For Laravel developers specifically: map the concepts once and move on β Service providers β plugins/modules + config/app.php; Eloquent β craft\elements\Entry Active Record + craft\db\Query; Blade β Twig (no @include, use {% include %} / {% embed %}); Artisan β php craft; php artisan tinker β php craft tinker isn't standard, use Craft::dd() in a template or a one-off console command.
Licensing is not MIT. GitHub reports NOASSERTION because Craft ships under the Craft License (Solo free, Pro/Enterprise paid) β budget and legal review before you assume "open source." The Dependents: 0 figure is also a signal: this is an application, not a library, so there is no ecosystem of packages depending on it. Upgrades are whole-app upgrades.
allowAdminChanges => false will bite you. With it off, plugin installs, field edits, and project config writes throw. That's correct for prod, but it means schema changes must land as YAML + php craft up. Don't try to "just tweak a field" on production.
Compiled templates are cleared by up, not migrate. In 5.9 the cache-flush responsibility moved. If you see stale markup after a deploy that only ran migrate, run php craft clear-caches/compiled-templates.
.all() without .limit() on a large section is the classic memory-exhaustion bug. Prefer .limit()/pagination, and .with() for relations. Also remember the default query excludes disabled entries and drafts β .status(null) when you actually want them.
Saving an element silently fails. saveElement() returning false gives you no message; always inspect $element->getErrors(). Validation failures, permission failures, and required-field errors all surface there.
Search and indexes go stale. After changing field handles or adding a site, run php craft resave/entries and php craft index-assets (or the equivalent index utility). New sites in 5.9 can be added to entries via the slideout, but the propagated content still needs a resave.
Queue workers are mandatory. Image transforms, Generate image transform jobs, system emails, and propagateAll all queue. maxCachedCloudImageSize now defaults to 0, so cloud image caching behavior differs from earlier versions β verify transforms in staging.
Twig is a security boundary. 5.9 restricts create() to yii\base\BaseObject subclasses and adds an enableTwigSandbox config. If you let content editors author Twig (or you eval user input), turn the sandbox on. Never |raw untrusted content.
GraphQL schemas need a post-upgrade audit. Making @parseRefs/@transform per-schema optional changes behavior for existing schemas β either re-enable the directives explicitly or update clients. New uuid() and randomString() Twig functions and |hash('sha256') are handy for generating cache-busting keys and stable identifiers.
Debugging path. Set devMode => true locally to get the Yii debug toolbar and full stack traces; logs live in storage/logs/web.log and storage/logs/console.log. Craft::dd($var) from a module or console command is the fastest tinker replacement. For "why is this template rendering this?" use {% dump %} or the EVENT_BEFORE_RENDER_TEMPLATE hook β 5.9 lets you rewrite the template path there, which is a clean seam for theming overrides.
Composer hygiene. Since 5.9, package constraints use caret operators β keep them and pin to a minor. composer update craftcms/cms frequently requires coordinated plugin updates plus php craft up; do that on a branch, never as a hotfix on prod.
How can I help you explore Laravel packages today?