craftcms/craft
Craft CMS is a flexible, developer-friendly content management system built on PHP and Twig. It offers powerful custom fields, multi-site support, and an intuitive control panel for editors, making it ideal for building bespoke websites and digital experiences.
craftcms/craft is not a library you require — it's the official Composer starter skeleton for Craft CMS. Think of it as the equivalent of laravel/laravel: you scaffold a project from it, then own the resulting codebase. Craft core itself is craftcms/cms.
Requirements for the current (Craft 5) skeleton: PHP 8.2+, MySQL 8.0+/MariaDB 10.4+/Postgres 13+, Composer 2. No Node build pipeline is included.
Minimal boot:
composer create-project craftcms/craft my-site
cd my-site
cp .env.example .env # set CRAFT_DB_*, CRAFT_SECURITY_KEY, CRAFT_PRIMARY_SITE_URL
php craft setup # interactive: DB creds, admin user, site URL
php craft serve # or point a vhost/DDEV at ./web
With DDEV (the path almost every Craft dev uses):
mkdir my-site && cd my-site
ddev config --project-type=craftcms --docroot=web --php-version=8.2
ddev composer create craftcms/craft
ddev craft setup
Where to look first, in order:
composer.json — pins the Craft version (craftcms/cms) and plugin set. This is your upgrade surface.config/general.php, config/db.php, config/app.php — the only three config files you'll touch regularly.config/project/ (or project.yaml on older 3.x) — Craft's project config, the serialized source of truth for sections, fields, entry types, sites, and most plugin settings.templates/ — Twig templates. web/index.php is the front controller; the docroot must be web/.modules/ — where you put app-specific PHP (Craft's equivalent of app/).storage/logs/ — web.log and console.log are your debugging ground truth.First meaningful use case: create a "News" section + entry type + a couple of fields in the control panel, add one entry, then render it:
{# templates/news/index.twig -> /news #}
{% set entries = craft.entries().section('news').orderBy('postDate DESC').limit(10).all() %}
{% for entry in entries %}
<a href="{{ entry.url }}">{{ entry.title }}</a>
{% endfor %}
Laravel mental-model mapping:
| Laravel | Craft |
|---|---|
php artisan |
php craft (or ddev craft) |
| Blade | Twig (auto-escaped, no @ directives) |
| Eloquent models | Elements + Query API (craft.entries(), Entry::find()) |
env('X') |
App::env('X') (Craft's dotenv, not Laravel's helper) |
config/*.php |
config/*.php + project config YAML for content modeling |
| Migrations | php craft migrate/* (schema for plugins, data moves) |
| Service container / facades | Craft::$app |
| Supervisor queues | Queue driver in config/queue.php + php craft queue/listen |
Project config as the deployment contract. The workflow that matters day-to-day:
# after any CP change (fields, sections, plugins)
php craft project-config/write
git add config/project && git commit
# on the next environment / after pull
php craft project-config/apply
php craft migrate/all
php craft clear-caches/all
Craft 4.4+ has php craft up, which chains migrate + project-config apply + cache clear — use that in your deploy script.
Custom logic lives in a module. Create modules/site/Site.php extending craft\base\Module, then register it in config/app.php:
return [
'modules' => ['site' => \modules\site\Site::class],
'bootstrap' => ['site'],
];
Add "modules\\site\\": "modules/site/" to composer.json autoload.psr-4 and composer dump-autoload -o. This is where you put service classes, Twig extensions, event listeners, and console controllers — not in templates and not by forking the skeleton.
Event hooks instead of patching core:
use craft\elements\Entry;
use yii\base\Event;
Event::on(Entry::class, Entry::EVENT_AFTER_SAVE, function (Event $e) {
/** @var Entry $entry */
if ($entry->section->handle === 'news') {
Craft::$app->queue->push(new \modules\site\jobs\NotifyJob(['entryId' => $entry->id]));
}
});
Eager-load relations in Twig — this is the single biggest perf lever:
{% set posts = craft.entries()
.section('blog')
.with(['featureImage', 'author', 'topics'])
.limit(12)
.all() %}
Console commands extend craft\console\Controller inside your module and are exposed as php craft <module-id>/<controller>/<action> — the closest thing to artisan make:command.
Headless / API surface: install craftcms/element-api for JSON endpoints (config/element-api.php), or use built-in GraphQL with schemas under config/graphql/schemas/ (project-config managed). For a Laravel front end consuming Craft, GraphQL with a scoped public schema + bearer token is the cleanest pattern.
Front-end build: the skeleton ships without one. Add craftcms/vite (or nystudio107/craft-vite) and use {{ craft.vite.script('src/main.js') }} in your layout.
Queue: set runQueueAutomatically => false in config/general.php and run php craft queue/listen under supervisor — otherwise jobs are kicked off by web requests and failures are silent. php craft queue/info and queue/retry are your friends.
Plugin installation is two steps: composer require vendor/plugin then php craft plugin/install <handle>, and commit the resulting project-config diff. Skipping the second step is the classic "works locally, broken on deploy."
composer require craftcms/craft. It's create-project only. Installing it into an existing app just drops a nested skeleton into vendor/.php craft project-config/apply and re-save the component in the CP and commit the clean output. If DB and YAML drift badly, php craft project-config/rebuild regenerates YAML from the database — run it locally only and review the diff before committing.allowAdminChanges => false in production is correct and non-negotiable, but it means the CP becomes read-only for schema. Plan every field/section change as a deploy. Gate it with App::env('CRAFT_ENVIRONMENT').devMode off in prod. It leaks stack traces and disables template caching in places. Gate it in config/general.php off CRAFT_ENVIRONMENT, not a hardcoded boolean.App::env() not env(). Laravel's helper doesn't exist here; Craft's dotenv populates $_ENV/$_SERVER and App::env is the supported accessor. CRAFT_* vars (CRAFT_DB_DATABASE, CRAFT_SECURITY_KEY, CRAFT_LICENSE_KEY) are read by the framework; your own vars are freeform.craft.entries().section('news') returns a query object. Forgetting .all() / .one() / .count() produces silent empty output or "not iterable" errors. one() returns null, not an exception — null-check before {{ entry.title }}.storage/, web/cpresources/, and config/ are the #1 cause of a 500 on first boot, especially in containers. The PHP user must be able to write there for caches, CP assets, and project-config writes../craft must run from the project root and via php craft on Windows (it's a PHP script, not a shell wrapper). With DDEV, always ddev craft.web/. If index.php is served from the project root you get exposed .env and config/ — a real security incident, not just a misconfiguration.CRAFT_LICENSE_KEY in .env or claim it in the CP before go-live.devMode on → php craft clear-caches/all → read storage/logs/web.log / console.log → php craft queue/info for stuck jobs → php craft project-config/apply if the CP shows fields that "don't exist."vendor/ will be destroyed by the next composer update.composer update craftcms/cms, plugin updates, php craft migrate/all, then project-config/apply. Start new projects from the matching branch/tag (5.x) or you'll scaffold an outdated project structure.How can I help you explore Laravel packages today?