Project and package manifests
Seven files tell grim what a project is made of. You write one of them, and part of a second.
Which file do you edit when you want another module, a second theme, a different server? And which files change under your hands when grim runs? Every manifest has one writer. Once you know who that is, you know whether an edit of yours will last. This tome takes the files one by one. What the version numbers and constraints in them mean is a separate subject, covered in Release trains.
| File | Written by | Read by |
|---|---|---|
grim.json |
You, and the commands that change the project | Almost every command |
grim.lock |
grim install, grim update, grim require |
grim install, grim update, grim release |
core.json |
The grim-core release | Every command that needs the train or the core module list |
module.json |
grim make:module, then the release |
The resolver, grim install, grim manifest:validate |
theme.json |
grim make:theme, then the release |
grim install, grim manifest:validate |
config/modules-available.php |
grim install, grim update |
The application |
storage/config/modules-enabled.php |
grim once, then you | The application |
grim.json is the one you write
grim create-project writes the first version. After that the file is yours, and it is one of the three files the project repository tracks. Three commands edit it for you: grim require adds to requires and themes, grim remove takes a module out of requires, and grim tune --auto writes a performance block. create-project also writes description and version. Both are labels for people, and no command reads them.
{
"name": "shop",
"core": "~2.5",
"php": "^8.5",
"requires": { "cart": "~2.5", "payments": "~2.5" },
"themes": { "frontend": "shop-front:~2.5", "admin": "float:~2.5" },
"docker": { "services": { "horizon": true, "scheduler": true, "reverb": false } },
"deploy": {
"health-timeout": 60,
"post-deploy": ["php artisan migrate --force"],
"environments": { "production": { "host": "203.0.113.10", "domain": "shop.example.com" } }
}
}| Key | Means |
|---|---|
name |
Required. Names the containers, the local domain shop.test, the image and the stack on the server. |
core |
Required. The constraint on grim-core, which decides the trains grim update may move to. |
php |
PHP version of the generated Dockerfiles. Leading ^, ~, >= are stripped. Default 8.5. |
requires |
Map of module name to constraint. Core modules are never listed here. |
themes |
The slots frontend and admin. |
docker, deploy |
Local and image settings, and where the project is deployed. Both have their own table below. |
tls |
mode: "custom" with domain, cert, key and optionally mail serves the local project on your own certificate and domain instead of shop.test. See LAN dev box with a custom certificate. |
deployment |
Written by grim server:dev:add-project on a dev box: kind, domain, vite_host, mailpit_host, tls. It switches the compose template. Do not write it by hand. |
performance |
The pm.* values of PHP-FPM. Written by grim tune --auto, read by grim tune. |
build.vite |
theme-frontend and theme-admin name the theme grim build compiles when .env names none. Without both, it is the first theme of the slot. |
A theme slot is a string or a list
A theme is written as slug:constraint. A slot with one theme stays a plain string. A slot with several is a list, ["shop-front:~2.5", "blank:~2.5"], and the first entry is the active theme. Every theme of a slot is installed. Which one the application shows is decided by THEME_FRONTEND and THEME_ADMIN in .env. grim build warns when .env names a theme the slot does not declare.
docker
| Key | Means |
|---|---|
services |
horizon, scheduler and reverb, each true or false. A missing key means true, true, false. Nothing else turns a service on. |
owner, image |
owner is the namespace of the image, ghcr.io/<owner>/grim-shop, default grimoiry. image is the whole name without a tag and wins over owner. |
timeout |
Seconds grim up and grim install wait for an image build and for the containers to start. grim install also reads the finer build_timeout, composer_timeout, up_timeout, yarn_timeout and container_wait. |
deploy
| Key | Means |
|---|---|
project |
The name of the stack on the server, when it should differ from name. |
host |
The server. Without it, the host registered by grim server:add-project is used. |
path |
The stack directory on the server. Default /opt/<stack>. |
domain |
The domain the router answers on. There is no default. A project without a domain does not deploy. |
health-timeout, pull-timeout |
Seconds to wait for the new containers, default 60, and seconds allowed for pulling the image. |
post-deploy |
List of commands run in the app container after the switch. Read from the top of deploy only. A failing one is a warning, not a rollback. |
environments |
Map of environment name to its own host, path, domain, health-timeout and pull-timeout. grim deploy looks here first, then at the top of deploy, then in the server registration. The top-level path and domain are production's and are skipped for every other environment. |
A deploy.domain at the top names production only. A staging stack without environments.staging.domain falls back to the domain in your own server registration, and without one the deploy stops with has no domain. Once a project has two environments, keep host and domain under environments so that every machine resolves the same targets. The recipe Staging to production, one image makes that move.
grim.lock is written whole, never edited
grim install, grim update and grim require all end by writing the lock again, complete. There is no command that patches one line of it, and you should not either. Commit it with grim.json.
{
"generated_at": "2026-03-02T09:14:07+00:00",
"grim_cli_version": "1.4.0",
"train": "2.5",
"core_version": "2.5.0",
"core_commit": "9f2c1e7a4b3d5c6e8f0a1b2c3d4e5f6a7b8c9d0e",
"modules": {
"cart": { "version": "2.5.3", "commit": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "requires": { "payments": "~2.5" } },
"payments": { "version": "2.5.1", "commit": "7e8f9a0b1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", "requires": [] }
},
"themes": { "frontend": [{ "name": "shop-front", "version": "2.5.0", "commit": "c0ffee1234567890abcdef1234567890abcdef12" }] },
"content_hash": "sha256:5d41402abc4b2a76b9719d911017c592..."
}| Key | Means |
|---|---|
train |
The train the project is on. An older lock without it gets it derived from core_version. |
core_version, core_commit |
The exact grim-core release and its commit. |
modules |
Every module in the project, the ones pulled in as dependencies included. Each has version, commit and the requires it declared. |
themes |
Per slot a list of name, version, commit, in the order of grim.json. |
generated_at, grim_cli_version, content_hash |
When the lock was written, by which grim, and a hash of everything except the date. |
modules is longer than requires in grim.json. You asked for cart. The lock also names payments, because cart requires it. grim why answers who brought a module in.
core.json comes with core
core.json sits in the project root and arrives with the grim-core release, like the rest of the skeleton. The next train overwrites it. It tells grim two things it cannot know otherwise: which train this core is, and which modules are part of core and therefore never belong in requires. Outside a project there is no core.json on disk, and create-project works from a copy bundled in grim.
{
"name": "core",
"version": "2.5.0",
"php": "^8.5",
"modules": ["admin", "auth", "common", "users"]
}| Key | Means |
|---|---|
name |
Required. Always core. |
version |
Required, X.Y.Z. Its first two parts are the train. make:module and make:theme stamp it into the manifest they write. |
php |
Required. One constraint string. A new grim.json inherits it. |
modules |
The core modules: kebab-case, unique, sorted, never empty. |
module.json describes one module
grim make:module writes the first module.json, in the module's own directory under src/. From then on you maintain three of its fields, and the release maintains two.
{
"name": "cart",
"namespace": "Cart",
"version": "2.5.3",
"type": "module",
"provider": "CartProvider",
"category": "commerce",
"requires": { "payments": "~2.5" },
"php": { "extensions": ["intl"] }
}| Key | Means |
|---|---|
name |
Required, kebab-case. The name used in requires and in grim require cart. |
namespace |
Required, PascalCase. The directory under src/ and the PHP namespace Modules\Cart. |
version |
Required, X.Y.Z. Written by the release. grim install warns when it differs from the tag it checked out. |
type |
Required. module or core-module. |
provider |
Required. PascalCase, ends with Provider. This class name goes into the two module lists. |
category |
One of commerce, insurance, finance, crm, content, communication, hr, ops, integrations, dev. Empty counts as uncategorized. |
requires |
Map of module name to a constraint of the form ~X.Y. Generated by the release from what the code imports. Never written by hand. |
php.extensions |
Extension names as php -m prints them. grim install warns when your PHP lacks one. |
The resolver follows requires from module to module, and the result is the modules block of the lock. The scaffold also writes description, routes and migrations. grim does not read them. The keys minimum-core and soft-requires are gone, and grim manifest:validate fails a manifest that still carries one. Run it in the module directory before you push. It applies the required fields and shapes of this table and names every error at once. The recipe Create a module shows the file in use.
theme.json is the shortest
grim make:theme writes it into resources/themes/<type>/<name>/.
{
"name": "shop-front",
"version": "2.5.0",
"type": "frontend",
"requires-modules": ["frontend"]
}| Key | Means |
|---|---|
name |
Required, kebab-case. The slug of slug:constraint. |
version |
Required, X.Y.Z. For a theme that exists only on your disk, this is the version the lock records. |
type |
Required. frontend or admin, the slot the theme fits. |
requires-modules |
List of module names in kebab-case. Checked for shape by manifest:validate. |
The two module lists
Both are PHP files that return a list of provider classes, one line per module, such as Modules\Cart\CartProvider::class.
config/modules-available.php is what is installed. grim install and grim update write it again every time, from the resolved modules, taking each class name from provider in module.json. An edit does not survive.
storage/config/modules-enabled.php is what the application boots. grim install creates it as a copy of the first list, only when it does not exist, and --no-enable skips even that. After that three commands touch it, one line at a time: grim require and grim make:module add a module, grim remove takes one out. Everything else in the file is your decision, and no update brings a line back that you deleted. What grim owns in your project has the rest of that division, including why only grim.json and grim.lock are committed.
Spells that do it
grim manifest:validate
Check a manifest
Validate module.json, theme.json and core.json against the manifest schema on your machine, before CI does it for you.
grim install
Assemble the whole project
Turn grim.json into a working project by fetching core, every module and theme, writing grim.lock, building the image and starting the stack.
grim require
Summon a module or theme
Add a module or theme to grim.json, install it with everything it depends on, and rewrite grim.lock, all inside the train the project is on.
grim deploy
Send an image to a server
Roll a pushed image tag out to one environment over SSH, verify the app answers, and fall back to the previous tag when it does not.
Related tomes
Release trains
Core, every module and every theme share one version line. What a train is, what moves it, and what a constraint in grim.json really allows.
What grim owns in your project
Which files in a project are yours, which grim rewrites, which command rewrites them, and why the repository of a project holds almost nothing.