Getting started

Introduction

Modular is an open-source, security-first CMS for PHP and Vue. Model your content once, then serve it through server-rendered themes, the headless API, or both at the same time.

If you have outgrown WordPress but miss its flexibility, Modular is built for you. It ships with the things most teams bolt on after the fact, a field builder, relational models, role-based access and a typed API, and nothing you have to vet a plugin marketplace for.

Philosophy

One content model, two delivery modes. The same collection can render a marketing page through a PHP theme and feed a mobile app through JSON, with no duplicate setup and no second system to keep in sync.

  • Headless and themed, choose per project, switch any time.
  • Self-hosted, your servers, your database, your data.
  • Security in the core, not a paid add-on, not a plugin.
  • Open source, MIT, public roadmap, no vendor lock-in.

Architecture at a glance

A PHP application server talks to MySQL or MariaDB. The admin is a Vue 3 single-page app that consumes the same API your projects do. Themes are plain PHP templates with a small set of helper functions. Capabilities beyond the core arrive as curated modules, never an open plugin free-for-all.

One core, many surfaces. The admin, your themes and your apps all read from the same versioned schema and the same permission layer.

Where to go next

Check the requirements, follow the installation steps, then work through the quickstart to model your first post type and read it back over the API.

Getting started

Requirements

What you need to run Modular in development and in production. The stack is deliberately boring and easy to host anywhere in the EU.

Runtime

ComponentMinimumRecommended
PHP8.18.2+
DatabaseMySQL 8.0 / MariaDB 10.6MySQL 8.0+
Web serverNginx or ApacheNginx + PHP-FPM
Node (admin build)1820 LTS
Composer2.4latest

PHP extensions

Modular needs a standard set of extensions, all common on shared and managed PHP hosts:

  • pdo, json, mbstring, openssl
  • gd or imagick for media transforms
  • intl for localisation and the Translation module

Hosting

Anything that runs PHP and MySQL runs Modular: a single VPS, a managed container platform, or Kubernetes. Persistent storage is required for the media directory. For EU data residency, pick a region you control, nothing phones home.

Sizing. A small project is comfortable on 1 vCPU / 1 GB RAM. Add workers and a cache layer as traffic grows, see Performance.
Getting started

Installation

Modular is a self-hosted PHP application. Clone the repository (or download the zip), point your web root at /public, configure the database, and run the setup wizard.

Get the code

Clone the repository or unzip a release, then point your web server's document root at the public directory.

terminalbash
git clone https://github.com/modular-cms/modular.git
cd modular
composer install

Configuration

Copy the example config and set your database credentials. This is the one file you edit by hand.

terminalbash
cp admin/config/config.local.php.example admin/config/config.local.php
admin/config/config.local.phpphp
<?php
return [
    'db_host' => '127.0.0.1',
    'db_name' => 'modular',
    'db_user' => 'modular',
    'db_pass' => 'secret',
    'base_url' => 'https://cms.acme.eu',
];

Create the schema

Open /admin/setup in your browser and follow the wizard, which creates the schema and the first admin user. To skip the wizard, import the full schema directly and run migrations.

terminalbash
# option B: import the schema, then apply migrations
mysql modular < core/migrations/full_schema.sql
composer migrate
Keep config.local.php out of version control. Use separate database credentials per environment.
Getting started

Quickstart

Model a post type, create an entry and read it back, first over the API, then rendered by a theme.

1. Create a post type

In the admin, open Post Types and create articles. Add a few fields with the field builder: a title (text, required), a body (wysiwyg) and an author (relationship). The slug is generated from the title.

2. Read it over the API

The public endpoint needs no auth, so a theme or external frontend can read published content directly.

terminalbash
curl "https://cms.acme.eu/api/v1/public/posts?type=articles"

3. Render it in a theme

public/themes/acme/single-article.phpphp
<?php
$post = get_post(null, $slug);
$data = json_decode($post['data'] ?? '[]', true); ?>
<article>
  <h1><?= e($post['title']) ?></h1>
  <?= $data['body'] ?? '' ?>
</article>
That is the whole loop: model in the UI, fetch over JSON, render with PHP. The same content now powers both surfaces.
Getting started

Upgrading

Modular follows semantic versioning. Minor releases are drop-in; major releases ship a guided upgrade path and a changelog of breaking changes.

The upgrade process

Back up the database, pull the new code, then run migrations. Always take a backup first and run the upgrade on staging before production.

terminalbash
# 1. back up the database first (mysqldump or your host's tool)
mysqldump modular > backup.sql

# 2. pull the new release and dependencies
git pull
composer install

# 3. apply schema + data migrations
composer migrate

Modules

Official modules declare the core version they require in their module.php header. After an upgrade, their migrations run when you re-activate them in the admin Modules screen. A module that requires a newer core stays inactive rather than loading against a mismatched pair.

Support policy

  • Security fixes land on the current and previous major.
  • Each major is supported for 18 months after the next major ships.
  • Deprecations are announced one minor in advance and logged at runtime.
Read the changelog. Major upgrades may change helper names or response shapes. Review your theme against the changelog before deploying.
Core concepts

Content modeling

Post types and their fields map directly to relational tables in MySQL. Define them in the admin Post Types builder, or create them programmatically.

Fixed and modular post types

A post type runs in one of two modes. A fixed type has a strict, schema-driven set of fields, good for a known shape like a product or an author. A modular type lets editors compose a page from reusable components. The supports setting controls which core features (title, editor, thumbnail) a type carries.

Defining a post type

Most teams use the admin builder. To script it, create the post type with the PostType class and store the field definitions as schema JSON.

create-articles.phpphp
$pt = new \Modular\PostType($db);
$pt->create([
    'name'     => 'Articles',
    'slug'     => 'articles',
    'mode'     => 'fixed',
    'supports' => ['title', 'editor', 'thumbnail'],
    'public'   => true,
]);

Each field is one entry in the type's schema JSON, stored in component_definitions:

field schema (JSON)json
{
  "fields": [
    { "name": "body",   "type": "wysiwyg", "label": "Body" },
    { "name": "author", "type": "relationship", "label": "Author" }
  ]
}

Validation

Validation rules are declared on the field and enforced on every write, in the admin editor and over the authenticated API alike. See the validation reference.

Core concepts

Fields reference

Modular ships a complete set of field types out of the box. Each maps to a column or relation and renders an appropriate editor.

TypeStoresEditor
textJSON in dataSingle-line input
textareaJSON in dataMulti-line input
wysiwygJSON in dataRich text editor
markdownJSON in dataMarkdown editor
numberJSON in dataNumeric input
true_falseJSON in dataToggle
datetimeJSON in dataDate & time picker
selectJSON in dataDropdown / radio
image / filemedia idAsset picker
relationshiprelated post idsReference picker
repeaterJSON in dataRepeatable group
flexible_contentJSON in dataComponent composer

The full set covers more than forty types, including code, embed, gallery, audio, video, checkbox, radio, button_group, date, time, range, currency, email, url, phone, password, taxonomy, color and layout helpers like tab, accordion, message and divider.

Custom field values live in a post's data column as JSON. In a theme, json_decode($post['data'], true) gives you every field by name.
Core concepts

Relations

Real relational models, not flattened metadata. Add a relationship field to point at other posts, and the public single-post endpoint resolves it for you.

Relationship fields

A relationship is a field type, configured in the Post Types builder. Point an article's author field at the authors post type, and a single relationship can reference one or many related posts. There is no separate relation DSL: a relationship is just another field in the schema.

Resolving relations

The list endpoint returns the bare post rows. The single-post endpoint resolves relationship fields and the author server-side, so one request gives you the related data already expanded.

requesthttp
GET /api/v1/public/posts/field-notes-7?type=articles
200 OKjson
{
  "id": 142,
  "slug": "field-notes-7",
  "title": "Field notes, no. 7",
  "author": { "id": 3, "name": "Lena Vos" },
  "content": {
    "related": { "type": "relationship", "data": [ { "id": 98, "title": "Field notes, no. 6" } ] }
  }
}
Core concepts

Validation

Declare rules once on the field. Modular enforces them in the admin editor and over the authenticated API, so there is no second place for a value to sneak in.

Built-in rules

Rules are declared on each field in its schema. They run server-side on every write.

field schema for the product post typejson
{
  "fields": [
    { "name": "sku", "type": "text", "required": true, "unique": true, "pattern": "^[A-Z0-9-]+$" },
    { "name": "price", "type": "currency", "required": true, "min": 0 },
    { "name": "summary", "type": "textarea", "max": 280 },
    { "name": "status", "type": "select", "options": ["draft", "published"], "default": "draft" }
  ]
}

Error responses

A failed write returns 422 with a per-field error map you can render straight into a form.

422 Unprocessable Entityjson
{
  "message": "The given data was invalid.",
  "errors": {
    "sku": [ "sku must be unique" ],
    "price": [ "price must be at least 0" ]
  }
}

Custom rules

A module can hook validation and add its own checks, for example a VAT-number rule, with add_filter.

public/modules/vat/module.phpphp
add_filter('validate_field', function ($errors, $field, $value) {
    if ($field['name'] === 'vat' && !vat_is_valid($value)) {
        $errors[] = 'Invalid VAT number';
    }
    return $errors;
}, 10, 3);
Validation runs on every write path. A user who lacks the capability to write a post type never reaches validation in the first place.
Core concepts

Media library

A single library for every asset, with on-the-fly transforms, focal-point cropping and metadata. Reference media from any collection with the media field.

Uploading

Drop files into the Media library in the admin, or push them over the authenticated API. Each upload is stored once and referenced by id, so the same asset can appear in many posts without duplication.

uploadbash
curl -X POST https://cms.acme.eu/api/v1/media \
  -H "Authorization: Bearer $JWT" \
  -F "file=@cover.webp"

Referencing media in a theme

A post's featured image and any media fields are stored as ids. Resolve a URL with the media helpers.

themephp
<?php $coverId = get_post_thumbnail_id($post['id']); ?>
<img src="<?= esc_url(get_attachment_url($coverId)) ?>"
     alt="<?= e($post['title']) ?>">
HelperReturns
get_post_thumbnail_id($id)The featured image id for a post
get_attachment_url($mediaId)The public URL for a media item

Storage

Uploads are written to the media directory under public, served as static files. Put that directory on persistent storage so assets survive a redeploy.

Editing

The block editor

A modular post type is built from blocks. Content is stored as portable, typed blocks, not a soup of HTML, so it renders cleanly in themes and travels intact over the API.

Built-in blocks

  • Headings, paragraphs, lists and quotes
  • Images and galleries from the media library
  • Code, tables and dividers
  • Embeds and call-to-action blocks

How it serializes

The public single-post endpoint returns the blocks as an array of typed objects, predictable to traverse and safe to render on any client.

GET /api/v1/public/posts/field-notes-7?type=articlesjson
{
  "blocks": [
    { "type": "heading", "level": 2, "text": "Field notes" },
    { "type": "paragraph", "text": "We rebuilt our model…" },
    { "type": "image", "media": 88, "caption": "The new editor" }
  ]
}

Rendering in a theme

In a theme the blocks live in the post's component_data column. Decode it and render each block with a template part named for its type.

single-article.phpphp
<?php
$blocks = json_decode($post['component_data'] ?? '[]', true);
foreach ($blocks as $block) {
    get_template_part('blocks/' . $block['type'], null, $block);
} ?>

Custom blocks

Define a new component type in the Post Types builder, then add a matching template part in your theme. Ship reusable components in a module to share them across projects.

Blocks are validated like fields. A malformed block never reaches the database, so your render layer can trust its input.
Editing

Revisions & drafts

Every save is a revision. Compare versions, restore an earlier one, and keep an unpublished draft alongside the live entry.

Draft vs published

An entry can hold a published version and a separate working draft. The public endpoint serves published content only. To read a draft, request it over the authenticated admin API with a JWT.

previewbash
curl https://cms.acme.eu/api/v1/posts/142 \
  -H "Authorization: Bearer $JWT"

History

Open any entry to see its timeline: who changed what, when. Restore a revision from the admin and it becomes a new revision, so nothing is ever lost.

Revisions are stored per entry. For high-write post types, prune old revisions periodically so the table stays lean.
Editing

Publishing workflow

Move content through stages, draft, in review, scheduled, published, with permissions deciding who can advance each step.

States

A post's status moves it through the workflow: draft, review, scheduled and published. Status is a column on the post, so the same value drives the admin, the theme and the API.

Scheduling

Set published_at in the future and the entry goes live automatically when a scheduled task publishes due posts. Add a cron entry that runs the publisher script on your server.

crontabcron
* * * * * php /srv/modular/core/cron/publish-scheduled.php

Permission gates

Each transition is gated by a capability, so only the right roles can advance a post. Authors hold post:review but not post:publish; editors hold both. Capabilities are assigned to roles in the admin under Roles.

in a publish handlerphp
if (!current_user_can('post:publish')) {
    require_auth();   // blocks the transition
}
Delivery

Headless API

A predictable REST API in two tiers: a public, no-auth tier for reading published content, and an authenticated tier for writes.

Reading published content

The public endpoints need no authentication. Pass the post type with type. The list endpoint returns up to 100 published posts; the single endpoint resolves relationships and the author.

requestbash
# list
curl "https://cms.acme.eu/api/v1/public/posts?type=articles"

# single, resolved, by slug
curl "https://cms.acme.eu/api/v1/public/posts/field-notes-7?type=articles"

Response shape

Responses are not wrapped in a data envelope. The list endpoint returns the post type and an array of posts.

200 OKjson
{
  "post_type": "articles",
  "posts": [
    { "id": 142, "slug": "field-notes-7", "title": "Field notes, no. 7", "status": "published", "published_at": "2026-03-12" }
  ]
}

There are no filter, fields, sort or pagination parameters: the list is capped at 100 and ordered by the server. Filtering happens in your theme or client.

Authenticated writes

Writes use the authenticated admin API. Obtain a JWT from POST /api/v1/auth/login, then send it as a Bearer token. Writes pass through validation and permissions.

createbash
curl -X POST https://cms.acme.eu/api/v1/posts \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{"post_type":"articles","title":"Hello","status":"draft"}'
EndpointPurpose
GET/POST/PUT/DELETE /api/v1/postsManage posts (auth)
GET /api/v1/post-typesList post types (auth)
GET /api/v1/meThe current user (auth)
POST /api/v1/auth/loginExchange credentials for a JWT
Delivery

GraphQL (on the roadmap)

Modular does not ship a GraphQL layer today. Delivery is the REST API. A GraphQL layer is on the public roadmap; until it lands, the patterns below show the REST equivalents.

Reading content today

The public REST endpoints serve published content with no auth. The single-post endpoint resolves relationships and the author server-side, so nested data arrives in one request.

GET /api/v1/public/posts?type=articlesbash
curl "https://cms.acme.eu/api/v1/public/posts?type=articles"

Response

200 OKjson
{ "post_type": "articles", "posts": [ { "id": 142, "title": "Field notes, no. 7", "slug": "field-notes-7" } ] }

Why REST today

  • REST is simple to cache, simple to consume, and already covers reading and writing every post type.
  • GraphQL, when it lands, will suit clients that need deeply nested data in one round trip.
A GraphQL delivery layer is tracked on the community roadmap. It will share the same data and the same permission model as the REST API, so nothing you build today is wasted.
Delivery

Theme development

Themes are plain PHP. No build step is required, and a small facade gives you typed access to your content.

Structure

public/themes/acme/tree
theme.json          # name, version
index.php           # homepage
single-article.php  # single article
archive-article.php # article listing
parts/
  header.php
  card.php
css/
  theme.css

Theme helpers

Query content with get_posts() and render each row with a template part. The post's custom fields live in its data column as JSON.

archive-article.phpphp
$posts = get_posts([
    'post_type'      => 'articles',
    'posts_per_page' => 12,
    'orderby'        => 'published_at',
    'order'          => 'DESC',
]);

foreach ($posts as $post) {
    get_template_part('parts/card', null, $post);
}

Routing

Modular maps a request to a template by post type and context: a single post renders single-{type}.php, a listing renders archive-{type}.php, and the home route renders index.php. Read the current post in a single template with get_post().

single-article.phpphp
$post = get_post(null, $slug);
include_header();
get_template_part('parts/article', null, $post);
include_footer();
Delivery

Template helpers

A small, predictable set of functions. Everything you need to query content, render template parts, build URLs and escape output, and nothing you have to memorise.

Querying

HelperReturns
get_posts([...])An array of published post rows
get_post($id)One post by id
get_post(null, $slug)One post by slug
get_terms([...])Taxonomy terms

Rendering, URLs & output

HelperPurpose
get_template_part($slug, $name, $data)Render a template part
include_header() / include_footer()Render the header / footer
render_menu($location) / get_menu('primary')Navigation
get_permalink($id) / base_url($path) / theme_uri($path)Build URLs
get_attachment_url($mediaId)A media URL
e(), esc_html(), esc_attr(), esc_url()Escape for output

Reading a field value

Custom field values are stored as JSON in the post's data column. Decode it once, then read fields by name.

phpphp
$data   = json_decode($post['data'] ?? '[]', true);
$author = $data['author'] ?? null;
echo e($data['summary'] ?? '');
Delivery

Caching

Render fast and stay fast. Modular caches resolved content and generated media, and gives you precise tools to invalidate exactly what changed.

Layers

  • Media transforms, generated once and written next to the original, so a resized image is computed a single time.
  • Full-page cache, optional, for themed routes with no per-user state.

Configuration

Caching is configured in admin/config/config.local.php. Enable the page cache and set a default lifetime.

admin/config/config.local.phpphp
return [
    // ...
    'page_cache' => true,
    'cache_ttl'  => 3600,   // seconds
];

Invalidation

Publishing or updating a post clears the cached pages that reference it. To clear everything by hand, delete the contents of the cache directory; there is no separate CLI command for it.

The cache lives on disk under your install. Putting it on fast local storage keeps cold renders quick.
Access & security

Permissions & roles

Role-based access is first-class. Roles hold capabilities, and every read and write path checks the same capabilities, for admin users and API clients alike.

Roles & capabilities

A role is a named set of capabilities, stored in the roles, capabilities and role_capabilities tables and managed in the admin under Roles. Common roles ship by default: admin, editor, author and viewer. Capabilities are plain strings like manage_options, post:create or post:publish.

Checking a capability

Anywhere in a theme or a module, ask the same question with current_user_can(). It takes a single capability string.

phpphp
if (is_logged_in() && current_user_can('post:publish')) {
    // show the publish button
}

if (current_user_can('manage_options')) {
    // administrator-only settings
}
Access is enforced by role and capability, not per field. A user who lacks a capability cannot reach the action behind it, through the admin, a theme or the API.
Access & security

Authentication & tokens

How users sign in and how machines authenticate. Sessions for the admin, JWT bearer tokens for the API, with the same role and capability model behind both.

User sessions

The admin uses signed, http-only session cookies with CSRF protection. Password hashing uses PHP's password API. The public read API needs no authentication at all.

API tokens

For authenticated API access, exchange credentials for a JWT at POST /api/v1/auth/login, then send it as a Bearer token on subsequent requests. The token carries the user's role, so it inherits the same capabilities.

terminalbash
# 1. log in, receive a JWT
curl -X POST https://cms.acme.eu/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"me@acme.eu","password":"…"}'

# 2. use it
curl https://cms.acme.eu/api/v1/me \
  -H "Authorization: Bearer $JWT"
Treat tokens like passwords. Send them only over HTTPS, keep their lifetime short, and never embed a JWT in client-side code that ships to the public read tier, which needs no token at all.
Access & security

Security hardening

Security lives in the core and ships on by default. There is no marketplace to audit and no abandoned plugin holding a CVE.

Secure defaults

  • CSRF protection on every state-changing request
  • Scoped, expiring API tokens
  • Rate limiting on auth and write endpoints
  • Security headers (HSTS, X-Frame-Options, CSP scaffold)
  • Argon2id password hashing

Headers & CSP

Set the security headers in admin/config/config.local.php. Force HTTPS, send HSTS, and add a Content-Security-Policy scaffold.

admin/config/config.local.phpphp
return [
    // ...
    'force_https' => true,
    'hsts'        => true,
    'csp'         => "default-src 'self'",
];

Activity log

The activity log is a core system module. Every sign-in, permission change and destructive action is recorded with the user behind it, viewable in the admin and available in the database for export to your SIEM.

Before going live, confirm HTTPS is forced, the config file is outside the web root's listing, and you are on a supported PHP version. The secure defaults above are on out of the box.
Modules

Modules overview

Modules are how you extend Modular, the equivalent of plugins, without the marketplace roulette. One curated, API-first module per capability, reviewed by us and supported in the core.

Why not plugins?

Open plugin ecosystems trade control for volume: a dozen competing options for the same job, uneven quality, abandoned code and the security surface that comes with it. Modules take the opposite stance.

  • One module per job. We develop a single official module for each capability, held to one standard.
  • API-first and fully wired. Every module integrates through the core, permissions, migrations and the headless API included.
  • Reviewed, or it does not ship. Anyone can build a module, but until it meets our requirements and we approve it, it is unsupported and never appears in your install.

Official modules

ModuleDoesStatus
SEOMeta, sitemaps, structured data, redirectsAvailable
TranslationPer-field localisation & workflowAvailable
CommunityAccounts, comments, profiles, moderationAvailable
Commerce, Forms, SearchIn development

How modules are funded

Some modules are commercial. Built and maintained by Atypisch, paid modules fund Modular's open-source core, so the platform itself stays free and open, for good.

Ready to add one? See installing modules. Want to build your own? Read building a module.
Modules

Installing modules

Modules are folders you drop into public/modules, then activate in the admin. Activation registers their hooks and runs their migrations.

Add a module

Place the module folder under public/modules, then open the admin Modules screen and activate it. Activation sets the module active and runs any migrations it ships, no command line involved.

terminalbash
# place the module, then activate it in admin → Modules
public/modules/seo/module.php
public/modules/seo/...

Manage

The Modules screen lists every module under public/modules with its status. Activate or deactivate from there; deactivating runs the module's module_deactivate hook. Remove a module by deleting its folder.

Configuration

A module exposes its settings in the admin once it is active. The SEO module, for example, takes a default title suffix and a sitemap toggle from its own settings panel, stored in the database rather than a config file.

System modules ship in the core under core/system/{slug}/module.json and load automatically. Add-on modules live in public/modules and load only after you activate them.
Modules

SEO module

Everything search engines need, managed from the admin and served by both your themes and the API. Meta tags, sitemaps, canonical URLs, structured data and redirects.

This is the reference. For an overview of what the module does, see the SEO module page.

Configuration

Place the module in public/modules/seo and activate it in the admin Modules screen. Defaults, like the title suffix and robots directive, are set in the module's settings panel and stored in the database. Everything else is managed per entry in the admin.

Per-entry SEO and the theme head

With the module active, an SEO panel is added to every post type: title, description, social image and indexing controls, with fallbacks from your content. In a theme, the head reads those values through filters.

parts/header.phpphp
<title><?= e(apply_filters('seo_meta_title', $post['title'] ?? '', $post)) ?></title>
<meta name="description" content="<?= e(apply_filters('seo_meta_description', '', $post)) ?>">

Sitemaps

A sitemap is generated and kept current as you publish, available at /sitemap.xml with no configuration. Add your own entries with the seo_sitemap_entries filter.

public/modules/your-module/module.phpphp
add_filter('seo_sitemap_entries', function ($entries) {
    $entries[] = ['loc' => base_url('/specials'), 'lastmod' => date('Y-m-d')];
    return $entries;
});

Redirects

Manage redirects in the admin or import them in bulk. The module also captures changed slugs automatically so old links keep working.

Structured data (JSON-LD) is emitted for articles, products and breadcrumbs out of the box. The module exposes its behaviour through filters and the /sitemap.xml route, not a dedicated API namespace.
Modules

Translation module

Per-field localisation with language fallbacks and a translation workflow. One content model, every locale, resolved through the same API.

This is the reference. For an overview of what the module does, see the Translation module page.

Locales

Activate the module in the admin Modules screen, then set your locales, the default and the fallback in its settings panel. The intl PHP extension is required.

Translatable fields

Mark a field translatable in the Post Types builder and the editor shows a tab per locale. Untranslated fields fall back to the default locale automatically.

field schema (JSON)json
{
  "fields": [
    { "name": "title", "type": "text",    "translatable": true },
    { "name": "body",  "type": "wysiwyg", "translatable": true }
  ]
}

Reading a locale

Pass locale to the public single-post endpoint to get the post in that language, with untranslated fields filled from the default locale.

requestbash
curl "https://cms.acme.eu/api/v1/public/posts/field-notes-7?type=articles&locale=de"
Locale is part of the cache key, so each language stays as fast as the default.

Roles per locale

Give translators a role with translation capabilities and scope them to the locales they own, so they can edit those languages and nothing else. Roles and capabilities are managed in the admin under Roles.

Modules

Community module

Add a members layer to any project, accounts, comments, profiles and moderation, without standing up a second system to keep in sync.

This is the reference. For an overview of what the module does, see the Community module page.

Member accounts

Visitors register, sign in and manage a profile. Members are a collection like any other, so they relate to your content and respect permissions.

Comments

The module adds its features as actions on the core hook system. A handler reads the signed-in member, validates the input and returns JSON. Nothing custom is needed in the core.

module.phpphp
add_action('community_post_comment', function () {
    $member = get_current_user();
    $entry  = (int) ($_POST['entry_id'] ?? 0);
    $body   = trim((string) ($_POST['body'] ?? ''));

    // validate, store, then return JSON
    echo json_encode(['success' => true, 'data' => ['id' => 9]]);
});

Every handler returns { "success": true, "data": { … } }. Members call the action with their own scoped token.

Moderation

Comments carry a status and can be hidden or removed from the admin. Reported content routes to a moderation queue.

Community actions run login and validation checks. Member sessions carry a member role, scoped so they can never reach editorial post types.

What the module provides

Activating the module registers a set of member-facing actions through the hook system, each backed by its own tables.

CapabilityWhat it adds
AccountsMember registration, sign-in and profiles
CommentsThreaded comments on an entry, with moderation
ReactionsLikes and follows related to your content
Modules

Building a module

Anyone can build a module. Build freely, test against the core, and submit it for review, but understand the bar before you start.

Anatomy

A public module is a folder under public/modules with a module.php entry file. Its metadata lives in a PHP docblock header at the top of that file.

public/modules/acme-events/tree
module.php          # header + hook registrations
migrations/
  001_events.sql    # run on activation
views/
  panel.php         # admin UI

Registering

Declare the module in the header docblock, then wire it up with add_action and add_filter.

public/modules/acme-events/module.phpphp
<?php
/**
 * Module Name: Acme Events
 * Version: 1.0.0
 * Description: Adds an events post type.
 * Author: Acme
 * Requires PHP: 8.1
 * Requires Modular: 1.0
 */

add_action('init', function () {
    // register routes, post types, admin pages
});

add_filter('the_title', fn($t) => $t);

Core hooks include init, module_activate, module_deactivate, admin_menu, the_title, the_content, public_routes, public_fixed_routes and seo_sitemap_entries. System modules instead ship a module.json manifest under core/system/{slug}.

The review bar

To be approved, supported and listed, a module must:

  • Be API-first, every feature reachable over the REST API.
  • Integrate with permissions, validation and migrations, no side doors.
  • Pass the security review.
  • Ship docs and a maintenance commitment.

Submitting

Test the module against a local install by dropping it in public/modules and activating it, then submit it for review through the project's repository. An unreviewed module runs only in your own install.

Until a module is approved it is unsupported and appears in no one else's install but your own.
Operate

Migrations

Every schema change is a versioned, reversible migration. Review them in pull requests and promote them between environments with confidence.

Running migrations

Apply pending migrations with Composer's script, or run the migration runner directly.

terminalbash
composer migrate                    # apply pending
# or, equivalently:
php core/migrations/migrate.php

Most model changes you make in the admin Post Types builder are applied directly. For schema changes that need version control, add a migration file by hand.

Writing one by hand

A migration is a SQL file in core/migrations, applied in order and tracked so it runs once.

core/migrations/2026_05_add_summary.sqlsql
ALTER TABLE posts
  ADD COLUMN summary TEXT NULL;
Operate

Command line & admin tasks

Modular has no all-in-one modular CLI. Day-to-day tasks happen in the admin; the few command-line steps use Composer and PHP directly.

TaskHow
Install dependenciescomposer install
Create the schemaRun /admin/setup, or import core/migrations/full_schema.sql
Apply migrationscomposer migrate (or php core/migrations/migrate.php)
Create a post typeAdmin → Post Types
Add a user with a roleAdmin → Users and Roles
Activate a moduleAdmin → Modules
Back up the databasemysqldump or your host's backup tool
Configuration lives in admin/config/config.local.php. There is no cache:clear, doctor or key:generate command, those needs are met by the admin, the config file, or migrations.
Operate

Deployment

Run Modular on your own infrastructure, in the region you choose. A production checklist for self-hosting.

Production checklist

  • Point the web root at public; keep admin/config/config.local.php out of version control.
  • Set force_https in the config and terminate TLS at your reverse proxy.
  • Run composer migrate on deploy.
  • Schedule database backups with mysqldump and test a restore.
  • Put the media directory on persistent storage.

Web server

Any PHP host with MySQL runs Modular: a VPS, shared hosting or a managed PHP platform. Point the document root at public and route requests through its front controller. A typical Nginx site looks like this.

nginx sitenginx
server {
  root /srv/modular/public;
  index index.php;

  location / {
    try_files $uri $uri/ /index.php?$query_string;
  }
}
Stuck? The community runs an active support forum and chat.
Operate

Backups & restore

Your data is yours, which means backups are your responsibility, and Modular makes them a one-liner. Snapshot the database and media, restore in minutes.

Creating a backup

A backup is the database plus the media directory. Use mysqldump for the data and copy the media folder.

terminalbash
mysqldump modular > modular-2026-05-18.sql
tar czf media-2026-05-18.tgz public/media

Restoring

terminalbash
mysql modular < modular-2026-05-18.sql
tar xzf media-2026-05-18.tgz

Scheduling

Run the backup from cron and rotate old files. This nightly job dumps the database and prunes dumps older than two weeks.

crontabcron
0 3 * * * mysqldump modular > /backups/modular-$(date +\%F).sql
0 4 * * * find /backups -name '*.sql' -mtime +14 -delete
Test your restores. A backup you have never restored is a guess. Restore to staging on a schedule.
Operate

Environment & config

One file, one source of truth. Configuration lives in admin/config/config.local.php, a plain PHP file that returns an array.

The config file

Copy the example, set your values, and keep the real file out of version control.

admin/config/config.local.phpphp
<?php
return [
    'db_host'     => '127.0.0.1',
    'db_name'     => 'modular',
    'db_user'     => 'modular',
    'db_pass'     => 'secret',
    'base_url'    => 'https://cms.acme.eu',
    'force_https' => true,
    'page_cache'  => true,
];

Per environment

Each environment keeps its own config.local.php with its own database credentials and base URL. The rest of the codebase is identical, so promoting between environments changes only this one file.

Keep config.local.php.example in version control as the documented template; keep the real config.local.php out of it.
Operate

Troubleshooting

When something is off, start here. Common symptoms, what causes them, and the command that tells you the truth.

Start with the basics

Most problems trace back to the config file, the database connection or a missing PHP extension. Confirm admin/config/config.local.php exists and has the right credentials, and check that migrations have run.

terminalbash
composer migrate              # apply any pending migrations
php -m | grep -E 'pdo|mbstring|gd'   # confirm extensions

Common issues

SymptomLikely causeFix
500 on every pageMissing or wrong config.local.phpCopy the example and set DB creds
Stale contentPage cache not invalidatedClear the cache directory
403 from the APICapability too narrowCheck permissions
Images not resizingMissing gd/imagickInstall the PHP extension
Migrations pendingDeploy skipped migratecomposer migrate

Logs

Application errors go to the PHP error log for your web server. Security and editorial events are recorded by the activity log system module and viewable in the admin.

Still stuck? Bring your config (with secrets removed) and the relevant log lines to the community forum, it tells us almost everything we need.