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
Component
Minimum
Recommended
PHP
8.1
8.2+
Database
MySQL 8.0 / MariaDB 10.6
MySQL 8.0+
Web server
Nginx or Apache
Nginx + PHP-FPM
Node (admin build)
18
20 LTS
Composer
2.4
latest
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.
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 migrationsmysql 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.
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 dependenciesgit pull
composer install
# 3. apply schema + data migrationscomposer 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.
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.
Type
Stores
Editor
text
JSON in data
Single-line input
textarea
JSON in data
Multi-line input
wysiwyg
JSON in data
Rich text editor
markdown
JSON in data
Markdown editor
number
JSON in data
Numeric input
true_false
JSON in data
Toggle
datetime
JSON in data
Date & time picker
select
JSON in data
Dropdown / radio
image / file
media id
Asset picker
relationship
related post ids
Reference picker
repeater
JSON in data
Repeatable group
flexible_content
JSON in data
Component 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
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.
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.
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.
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.
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.
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
# listcurl"https://cms.acme.eu/api/v1/public/posts?type=articles"# single, resolved, by slugcurl"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.
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"}'
Endpoint
Purpose
GET/POST/PUT/DELETE /api/v1/posts
Manage posts (auth)
GET /api/v1/post-types
List post types (auth)
GET /api/v1/me
The current user (auth)
POST /api/v1/auth/login
Exchange 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.
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.
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().
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.
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 JWTcurl -X POST https://cms.acme.eu/api/v1/auth/login \-H"Content-Type: application/json"\-d'{"email":"me@acme.eu","password":"…"}'# 2. use itcurl 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.
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.
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.
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.
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.
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.
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.
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.
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 TEXTNULL;
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.
Task
How
Install dependencies
composer install
Create the schema
Run /admin/setup, or import core/migrations/full_schema.sql
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.
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.
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.