⌘K

Data Layer

Synaptik CMS uses a split-file architecture: each content item is its own JSON file, with a lightweight per-type index for fast list pages that never need to read every item.

Two entry points, two responsibilities — both are now thin loaders over focused modules (see File Structure), but every function below still lives under the same public name regardless of which module actually defines it:

LoaderFunction prefixRole
core/data-layer.phpsl_Read-only — content types, index, items, categories, tags, cache
core/admin-data-layer.phpsl_admin_Write — content-type registration, save, delete, trash, restore, revisions, index rebuild

Content types are not hardcoded

There is no fixed list of "article/page/project" in the data layer — article, page, and project are simply the three built-in entries that ship pre-registered. Anyone can add a custom content type from the admin (Content Types) without touching code.

The registry lives in data/content-types.json:

{
  "article": { "url_base": "article", "supports_taxonomies": true, "supports_status": true, "built_in": true },
  "page":    { "url_base": "page",    "supports_taxonomies": true, "supports_status": true, "built_in": true },
  "project": { "url_base": "project", "supports_taxonomies": true, "supports_status": true, "built_in": true },
  "recettes": { "url_base": "recettes", "supports_taxonomies": true, "supports_status": true, "built_in": false }
}
FunctionPurpose
sl_content_types(): arrayThe full registry — reads data/content-types.json, cached; falls back to just the 3 built-ins if the file is missing or empty
sl_all_type_slugs(): arrayarray_keys(sl_content_types())
sl_content_type(string $type): ?arrayOne type's definition, or null
sl_content_type_exists(string $type): boolGuard to use before treating a $_GET['type']/route segment as valid
sl_type_dir(string $type): stringStorage directory name — pluralizes only the three built-ins (article → articles); a custom type's directory is its identifier exactly as entered, e.g. a type named recettes stores at data/recettes/, never data/recettess/
sl_url_base_map(?string $exceptType = null): array / sl_url_base_available(string $urlBase, ?string $exceptType = null): boolUniqueness/reserved-word check for a type's public URL segment

Any code that still branches on $type === 'article' || $type === 'page' || $type === 'project' with no else for anything else is a bug, not a style choice — a custom type falling through that kind of guard is exactly the pattern that broke URLs, the admin dashboard dispatch, and the topbar "Add" menu the first few times this registry was used for real. Always guard with sl_content_type_exists($type) and give every branch chain a generic fallback.

url_slug($type) (core/functions.php) is what resolves a type to the segment that appears in a real URL: it checks a site owner's renamed label override first, then a custom type's url_base, then the raw identifier — see Content Model Reference and the theme contract's theming rules for what this means for template code.

A custom type with no dedicated content-{type}s.php theme template renders through theme/default/content-generic.php instead (resolved by sl_resolve_template_name(), checking child theme → theme → default → root) — title, date, tags, category, featured image, custom fields, and content body, no theme work required to make a new type render something reasonable.


Index vs. full item

Lightweight index (_index.json), loaded on every request: _file, slug, custom_slug, title, date, category, tags, image, show_in_menu, menu_order, plus per-type extras (articles → summary, show_on_homepage; projects → description, show_on_homepage; pages → page_template). sl_admin_index_fields(string $type): array (core/admin/index-store.php) is the authoritative list — if you need a value available in list views without loading the full item, it has to be added there.

Full item file ({slug}.json), loaded only for single-item views: everything else — content, SEO fields, galleries, show_date, show_title, og_image, custom fields, author, revisions pointer.

⚠️

$GLOBALS['data'][$type] is volatile: the index array on list pages, a single-item array on single-item pages. Never assume it holds the full catalogue — call sl_load_index($type) instead.


Caching

Every read function is cached per-request in $GLOBALS['_sl_cache'], and across requests via APCu when the extension is loaded, falling back to a compiled file cache (cache/sl_idx_{type}.cache.php, cache/sl_categories.cache.php, cache/sl_tags.cache.php, plus a content_types cache entry for the registry) otherwise. The write layer invalidates both on every operation.

A separate cache — the full-page HTML cache (sl_page_cache_get()/sl_page_cache_set(), core/data/page-cache.php) — has its own key-safety rule: the key must cover every input that can change the output (language, scheme, host, etc.), not just the URL path. See Security for the 1.4.1 bug this rule was written to prevent from recurring, and tests/Unit/PageCacheKeyTest.php for the regression test that locks it in. Apply the same reasoning to any new cache you add (fragment cache, API response cache) — list every input before picking the key.


Read functions (sl_*, core/data-layer.php)

FunctionPurpose
sl_load_index(string $type): arrayLightweight index for any registered type
sl_load_item(string $type, string $fileSlug): ?arrayFull item by its _file slug
sl_load_item_by_slug(string $type, string $slug): ?arrayFull item by public slug (custom_slug ?: slug) — index lookup, no raw-slug fallback
sl_load_all_items(string $type): arrayEvery full item for a type. Expensive — only for search, SEO overview, sitemap
sl_item_path(string $type, string $fileSlug): stringAbsolute path to an item's JSON file; applies basename() first
sl_load_categories(): array / sl_load_tags(): arrayTaxonomy stores
sl_find_in_index(string $type, string $slug): ?array[entry, position] from an index, or null
sl_build_data_array(?array $types = null, bool $fullItems = false): arrayAssembles the $data array consumed by index.php; null resolves to every registered type inside the function body (it can't be a literal default — a function call isn't a valid PHP default parameter value)
sl_effective_slug(array $item): stringcustom_slug ?: slug
sl_promote_scheduled(string $type): voidFlips scheduled items to published once their publish date has passed

Write functions (sl_admin_*, core/admin-data-layer.php)

Content-type registry

sl_admin_register_content_type(string $slug, array $definition), sl_admin_update_content_type(string $slug, array $changes), sl_admin_unregister_content_type(string $slug) — validate the slug against [a-z0-9\-_]+, reject reserved words (category, tag, home, search, admin) and duplicates, refuse to delete a built-in or a type that still has content. Each returns true on success or an already-translated error string on failure — check with === true before treating the result as success, not a truthy check.

Items & index

sl_admin_save_item(), sl_admin_delete_item(), sl_admin_write_index(), sl_admin_update_index(), sl_admin_remove_from_index(), sl_admin_save_categories(), sl_admin_save_tags(). All writes go through _sl_write_json() (core/admin/writer.php): encode, write to {path}.{getmypid()}.tmp, then rename(). The PID in the temp filename matters — a fixed {path}.tmp name lets two near-simultaneous writes to the same file interleave and silently lose one of them; this was a real bug fixed across _sl_write_json() and every plugin's own data layer.

Trash

Deleted items move to data/{type}/.trash/ instead of being removed outright:

sl_admin_trash_item(), sl_admin_restore_trashed_item(), sl_admin_purge_trashed_item(), sl_admin_purge_all_trash(), sl_admin_purge_expired_trash(int $maxAgeDays = 30) (auto-run on a schedule; default 30-day retention), sl_admin_load_trash_index(string $type).

Revisions

Every save snapshots the previous version to data/{type}/.revisions/{fileSlug}/{timestamp}.json:

sl_admin_snapshot_revision(), sl_admin_list_revisions(), sl_admin_load_revision(), sl_admin_restore_revision(), sl_admin_delete_revision(), sl_admin_delete_all_revisions(), sl_admin_migrate_revisions() (reattaches history when a slug changes).

Activity log

sl_admin_log_activity(string $action, string $details = '') appends to private/activity-log.json, read back by sl_admin_load_activity_log(). Used for logins, template edits, extension installs, user changes, and restores — surfaced on the Activity Log page.

Backup / full export

sl_admin_save_all(array $data) and sl_admin_load_all() handle the full-site JSON export/import used by the Backup tool — both loop sl_all_type_slugs(), so a custom type is included automatically.


How index.php loads data

// 1 — lightweight index for every registered type, always
$data = sl_build_data_array(null, false);
$GLOBALS['data'] = $data;

// 2 — single-item view: overwrite with the one full item
$data[$type] = [$fullItem];
$GLOBALS['data'] = $data;