The CMS - draganescu/rasterPHP GitHub Wiki
Raster's content management system stores the text, pictures and lists that editors change. It has no schema file and no admin configuration: it reads your templates and works out what content exists from the annotations. This page explains the two kinds of content (page fields and collections), their URLs, drafts and scheduling, and how to change fields safely later.
Editors change content in the page itself; see The in-page editor. AI agents can change it too; see Working with AI agents.
A print.cms.<name> annotation declares a page field:
<h1><!-- print.cms.headline -->Hello<!-- /print.cms.headline --></h1>
<div class="intro"><!-- print.cms.intro --><p>Some <strong>rich</strong> text.</p><!-- /print.cms.intro --></div>- The content between the tags is the field's default, and its starting value.
- Values are stored per page URL:
headlineon/aboutandheadlineon/teamare separate fields. - Values can contain HTML.
- An empty value shows the template's default again.
- Every save stores a new revision of the page, so nothing is lost. Two people saving different fields at the same moment both keep their change. The editor shows the history and can bring back an older version.
A field with no default (<!-- print.cms.subtitle /-->) works, but lint warns about it: an empty field is invisible in the editor, so give it some starting text.
Page fields can fill attributes, which is how you get editable images, links and meta tags:
<!-- [email protected]_photo --><img src="images/hero.jpg" alt=""><!-- /[email protected]_photo -->
<!-- [email protected]_link --><a class="button" href="contact.html">Get in touch</a><!-- /[email protected]_link -->
<!-- [email protected] --><meta name="description" content="A short description of the page"><!-- /[email protected] -->
<title><!-- print.cms.title -->About us<!-- /print.cms.title --></title>Until an editor uploads a picture, the template's picture shows. Fields the page can't show as editable (in <head>, inside attributes) are listed in the editor's Page panel.
A field whose name starts with site_ is shared by every page:
<a class="brand" href="index.html"><!-- print.cms.site_name -->My Company<!-- /print.cms.site_name --></a>
<footer><!-- print.cms.site_footer -->© My Company<!-- /print.cms.site_footer --></footer>Put these in _layout.html so they appear everywhere. Changing one changes it on every page.
A render.cms.<name> block declares a collection: a list of items with the same fields, such as news posts, products, team members or FAQs. The fields are the print keys inside the block:
<!-- render.cms.news -->
<article>
<h2><!-- print.headline -->Our new office<!-- /print.headline --></h2>
<p class="date"><!-- print.date -->2026-09-01<!-- /print.date --></p>
<!-- [email protected] --><img src="images/news.jpg" alt=""><!-- /[email protected] -->
<div><!-- print.summary --><p>We moved.</p><!-- /print.summary --></div>
</article>
<!-- /render.cms.news -->- The first time the block renders, the collection gets one item made from the template's sample content, so the page never starts empty.
- A collection can appear on several pages (the home page shows three, the news page shows all). All the fields used anywhere belong to it.
- Adding a new
print.keyto the block adds a field. The most recent item gets the template's text as its starting value; older items start empty until someone fills them in. - Items are not versioned the way pages are; each save replaces the item's values.
Pass options as one string, in the style of a URL query:
<!-- render.cms.news('order=newest&limit=3') -->
<!-- render.cms.products('featured=1&order=name') -->
<!-- render.cms.events('date>=today&order=date,time') -->
<!-- render.cms.products('category=?category&order=-price') -->| Option | Meaning |
|---|---|
order=newest |
newest first (by publish date, falling back to last update) |
order=oldest |
in the order they were added (the default), not by any date field |
order=<field> |
by that field, A to Z |
order=-<field> |
by that field, Z to A |
order=date,time |
by several fields, each may have its -
|
limit=<n> |
at most n items (otherwise the page size, see below) |
<field>=<value> |
only items whose field equals the value |
<field>=?<name> |
equals the URL's ?<name>=; left out when the URL has none or it is empty |
<field>>=<value> |
also >, <, <=, and != (which keeps items where the field is empty) |
today is the date the page is shown: date>=today is what's coming, date<today what's past. Values compare as the field's type: a field whose mock-up is 2026-10-10 is a date, 14 an int, 4.50 a number, 19:00 a time, and anything else text. Whoever writes a value, it is stored as its type (5 Oct 2026 becomes 2026-10-05), or refused when it can't be one.
A filter on a field that doesn't exist yet adds that field. So render.cms.products('featured=1') gives products a featured field, which editors set to 1 on the items to show.
field=?name makes a plain search or filter form work with no model code:
<form method="get">
<select name="category"><option value="">All</option><option>Mugs</option><option>Bowls</option></select>
<button>Show</button>
</form>
<!-- render.cms.products('category=?category&order=name') --> … <!-- /render.cms.products('category=?category&order=name') -->/shop?category=Mugs shows the mugs, and the form shows Mugs chosen: forms with method="get" keep what the URL asked. A new item an editor adds to the filtered list starts as a mug. Pages with a query string are never cached.
Remember: the closing tag repeats the options exactly, <!-- /render.cms.news('order=newest&limit=3') -->.
Each collection gets URLs without any routing work:
| URL | Shows |
|---|---|
/news/news_item/our-new-office |
one item, by its slug |
/news/news_item/3 |
one item, by its id |
/news/news_page/2 |
page 2 of the list |
/news/news_items/tag/php |
only the items whose tag field is php
|
Item URLs always start with the collection's own name: /<collection>/<collection>_item/…. /news/news_item/… uses news_item.html if it exists, otherwise news.html, and one of them must exist and contain render.cms.news. Either way, that block shows just the one item. An item that doesn't exist gives a 404. So if your team list is on people.html, create team_item.html for the detail pages (or name the list page team.html).
List URLs (_page and _items) start with the page that shows the list: /news/news_page/2 for news.html, /archive/news_page/2 for archive.html. A collection listed on the home page is an exception: /news_page/2 has no page to attach to and gives a 404, so put long, paginated lists on a page of their own.
Page fields on these URLs. List pages (/news/news_page/2, /news/news_items/tag/php) share their page fields with /news. All item pages of a collection share one set of page fields (for example a common sidebar text): their own when news_item.html exists, otherwise those of /news.
Each item has a ready-made link to its own page:
<!-- [email protected]_detail_link --><a href="news/news_item/1"><!-- print.headline -->Title<!-- /print.headline --></a><!-- /[email protected]_detail_link -->raster_filter@<field> links to the list of items that share this item's value for that field:
By <!-- [email protected]_filter@author --><a href="#"><!-- print.author -->Ada<!-- /print.author --></a><!-- /[email protected]_filter@author -->For an item whose author is Ada, this links to /news/news_items/author/Ada/. Several fields work too: raster_filter@author@year.
Lists show 10 items per page unless you say otherwise:
config::set('raster_page_size')->to(12); // every collection
config::set('news_page_size')->to(5); // just newsA limit option in the template wins over both. To show page links, see Pagination.
Items relate by value: two news items with the same author text are "by the same author", and raster_filter@author lists them. There are no foreign keys. If you need real references between records (an order with many order lines), write a model with its own tables; see The database.
Every item gets a slug, a readable id for its URL, made from its title, headline or name field (or else its first text field). Accents are turned into plain letters: Café crème becomes cafe-creme. If the slug is taken, -2, -3 and so on are added, even for items saved at the same moment. Editors can change it in the item's details.
Every item has two extra fields:
-
enabled:0makes it a draft (the editor calls this Hidden). -
published_at: a date and time in the future schedules it.
Visitors don't see drafts or scheduled items, on lists, detail pages, feeds or sitemaps. Logged-in editors see everything, so they can check a post before it goes out. When a scheduled item's time comes, the page cache is thrown away so it shows up.
- Field and collection names: lowercase letters, digits and
_, starting with a letter. - Reserved field names:
slug,id,updated_at,enabled,published_at, and the names of thecmsmodel's own methods (style,login,logout,route,setup, and others). - Reserved collection names:
users,raster. - Words SQL keeps for itself (
when,from,group,order) are fine as field names: lists sort and filter by them like any other field.
lint reports a reserved or invalid name with its file and line.
The markup is the schema, so renaming an annotation means renaming a column. php bin/raster schema shows how your templates and the database compare:
page /about (about.html) table aboutpage, 3 row(s)
✓ title "About"
+ subtitle "Who we are"
- heading (in database, not in any template)
renamed? php bin/raster schema --rename=aboutpage.heading:subtitle
-
✓the field exists in both -
+the template has it, the database doesn't yet -
-the database has it, no template uses it any more (an orphan)
When you rename an annotation, Raster notices the likely rename and suggests the command that moves the content across, so nothing editors wrote is lost:
| Command | Does |
|---|---|
php bin/raster schema |
shows the comparison |
php bin/raster schema --apply |
creates missing tables and columns (needed in production, where the database is frozen) |
php bin/raster schema --rename=aboutpage.heading:subtitle |
renames a column, keeping its content |
php bin/raster schema --drop=aboutpage.heading |
deletes a column no template uses |
php bin/raster schema --drop=olddata |
deletes a whole table no template uses |
--force |
together with --drop, deletes even if a template still uses it |
php bin/raster schema --check |
exits with code 1 when templates and database differ, for automated checks |
--json |
the same information as JSON |
--drop refuses to delete something a template still uses unless you add --force.
In production (frozen database), a field you added to a template shows its default until you run schema --apply there.
config::set('cms_enabled')->to(false);print.cms and render.cms then keep their template content, and the editor isn't offered.