⚠️ Documentation Under Review: This documentation is currently being updated and verified against the actual implementation. Some information may be incorrect or incomplete. Please verify all code examples against the actual source code before use.
The Neuron CMS provides a complete content management system with two primary content types:
Both content types use Editor.js for rich block-based content editing and share common features like SEO optimization, publishing workflows, shortcode support, and author attribution.
Blog posts are time-based content designed for news, articles, and regular updates. They use Editor.js for block-based content editing and support categorization, tagging, shortcodes, and RSS feed generation.
Location: Neuron\Cms\Models\Post
The Post model extends Neuron\Orm\Model and uses PHP 8+ attributes for ORM relationships:
use Neuron\Orm\Model;
use Neuron\Orm\Attributes\Table;
use Neuron\Orm\Attributes\BelongsTo;
use Neuron\Orm\Attributes\BelongsToMany;
#[Table( 'posts' )]
class Post extends Model
{
#[BelongsTo( User::class, 'author_id' )]
protected User $author;
#[BelongsToMany( Category::class, 'post_categories' )]
protected array $categories;
#[BelongsToMany( Tag::class, 'post_tags' )]
protected array $tags;
}
Key Fields:
id - Auto-increment primary keytitle - Post title (255 chars)slug - URL-friendly identifier (auto-generated from title)content_raw - Editor.js JSON content (stored as text)
body - Legacy HTML content field (deprecated, use content_raw)excerpt - Short summary (optional)featured_image - Featured image URL (optional)author_id - Foreign key to users tablestatus - Publication status (draft/published/scheduled)published_at - Publication timestampview_count - Number of viewscreated_at, updated_at - TimestampsRelationships:
author - BelongsTo Usercategories - BelongsToMany Category (via post_categories)tags - BelongsToMany Tag (via post_tags)use Neuron\Cms\Enums\ContentStatus;
Draft (ContentStatus::Draft)
published_at is nullPublished (ContentStatus::Published)
published_at set to current timestamp (or specified date)Scheduled (ContentStatus::Scheduled)
published_at set to future date/admin/posts/createuse Neuron\Cms\Services\Post\Creator;
use Neuron\Cms\Models\Post;
$creator = new Creator( $postRepository, $categoryRepository, $tagResolver );
$editorContent = json_encode( [
'blocks' => [
[
'type' => 'header',
'data' => ['text' => 'My Blog Post', 'level' => 2]
],
[
'type' => 'paragraph',
'data' => ['text' => 'This is my post content.']
]
]
] );
$post = $creator->create( title: 'My Blog Post',
content: $editorContent,
authorId: $user->getId(),
status: ContentStatus::Published,
excerpt: 'Optional excerpt',
categoryIds: [1, 2],
tagNames: 'technology, php, tutorial'
);
Creator - Neuron\Cms\Services\Post\Creator
Updater - Neuron\Cms\Services\Post\Updater
Publisher - Neuron\Cms\Services\Post\Publisher
Deleter - Neuron\Cms\Services\Post\Deleter
Model: Neuron\Cms\Models\Category
Creating Categories:
use Neuron\Cms\Services\Category\Creator;
$creator = new Creator( $categoryRepository );
$category = $creator->create( name: 'Technology',
slug: 'technology', // Optional, auto-generated
description: 'Tech news and updates' );
Assigning to Posts:
$post->setCategories( [$category1, $category2] );
$postRepository->update( $post );
Model: Neuron\Cms\Models\Tag
Creating Tags:
use Neuron\Cms\Services\Tag\Creator;
$creator = new Creator( $tagRepository );
$tag = $creator->create( name: 'PHP',
slug: 'php' );
Tag Resolution: Tags can be created on-the-fly during post creation by passing tag names that don't exist yet.
PostRepository Methods:
// Get all published posts
$posts = $postRepository->getPublished( $limit = 10 );
// Get posts by category
$posts = $postRepository->getByCategory( $categoryId, $limit = 10 );
// Get posts by tag
$posts = $postRepository->getByTag( $tagId, $limit = 10 );
// Get posts by author
$posts = $postRepository->getByAuthor( $authorId, $limit = 10 );
// Get single post by slug
$post = $postRepository->findBySlug( 'my-post-slug' );
// Search posts
$posts = $postRepository->search( $query, $limit = 10 );
// Get drafts
$drafts = $postRepository->getDrafts( $authorId );
// Count posts
$count = $postRepository->count( $status = null );
Auto-Slug Generation:
// Automatically converts "My Blog Post" to "my-blog-post"
$slug = $creator->generateSlug( $title );
Meta Tags:
RSS Feed:
/rssPages are static or semi-dynamic content designed for "About", "Contact", "Services" pages, etc. They use Editor.js for block-based content editing and support shortcodes for dynamic content insertion.
Location: Neuron\Cms\Models\Page
Key Fields:
id - Auto-increment primary keytitle - Page title (255 chars)slug - URL-friendly identifiercontent - Editor.js JSON content (stored as text)template - Template name (default/full-width/sidebar/landing)meta_title - SEO title (optional)meta_description - SEO description (optional)meta_keywords - SEO keywords (optional)author_id - Foreign key to users tablestatus - Publication status (draft/published)published_at - Publication timestampview_count - Number of viewscreated_at, updated_at - TimestampsRelationships:
author - BelongsTo UserTemplates:
default - Standard page layoutfull-width - Full-width content (no sidebar)sidebar - With sidebarlanding - Landing page layout/admin/pages/createuse Neuron\Cms\Services\Page\Creator;
use Neuron\Cms\Models\Page;
$creator = new Creator( $pageRepository, $eventDispatcher );
$editorContent = json_encode( [
'blocks' => [
[
'type' => 'header',
'data' => ['text' => 'Welcome', 'level' => 2]
],
[
'type' => 'paragraph',
'data' => ['text' => 'This is my page content.']
]
]
] );
$page = $creator->create( title: 'About Us',
content: $editorContent,
authorId: $user->getId(),
status: Page::STATUS_PUBLISHED,
template: Page::TEMPLATE_DEFAULT
);
Both Pages and Blog Posts use Editor.js for content editing with the following supported blocks:
Content Structure:
{
"blocks": [
{
"type": "header",
"data": {
"text": "Page Title",
"level": 2
}
},
{
"type": "paragraph",
"data": {
"text": "Your content here..."
}
}
]
}
Both Pages and Posts are rendered server-side for SEO:
use Neuron\Cms\Controllers\Pages;
use Neuron\Cms\Services\Content\EditorJsRenderer;
// Pages controller
$page = $this->pageRepository->findBySlug( $slug );
$content = $page->getContent(); // Returns array from JSON
$html = $this->editorRenderer->render( $content );
// Blog controller (Posts)
$post = $this->postRepository->findBySlug( $slug );
$content = $post->getContent(); // Returns array from JSON
$html = $this->editorRenderer->render( $content );
The EditorJsRenderer converts Editor.js JSON to Bootstrap-styled HTML with shortcode support.
Shortcodes allow you to embed dynamic content within both Pages and Blog Posts using a simple [shortcode] syntax, similar to WordPress.
Example:
[latest-posts limit="5"]
[contact-form recipient="[email protected]" title="Contact Us"]
[pricing-table]
EditorJsRenderer passes content through ShortcodeParserShortcodeParser finds shortcodes and calls registered widget handlersArchitecture:
Page Content (Editor.js JSON)
↓
EditorJsRenderer parses blocks
↓
Finds shortcodes like [widget attr="value"]
↓
ShortcodeParser extracts shortcode + attributes
↓
WidgetRegistry finds registered widget
↓
Widget renders to HTML
↓
Final HTML output
Shortcode: [latest-posts]
Attributes:
limit (default: 5) - Number of posts to displaycategory (optional) - Filter by category slugExamples:
[latest-posts limit="3"]
[latest-posts limit="10" category="technology"]
Output: Bootstrap card grid with post titles, excerpts, dates, and links to full articles
Shortcode: [calendar]
Attributes:
limit (default: 5) - Maximum number of events to displayupcoming (default: true) - Show upcoming events (true) or past events (false)category (optional) - Filter by event category slugExamples:
[calendar]
[calendar limit="10"]
[calendar upcoming="false" limit="5"]
[calendar category="conferences" limit="3"]
Output: Unordered list of events with titles, dates, times, and locations. Each event links to its detail page at /calendar/event/{slug}.
Features:
CSS Classes:
.calendar-widget - Container div.calendar-widget-list - UL list wrapper.calendar-widget-item - LI for each event.event-link - Anchor tag for event.event-title - Event title span.event-date - Event date/time span.event-location - Event location span (if present)<?php
namespace App\Widgets;
use Neuron\Cms\Services\Widget\Widget;
class MapWidget extends Widget
{
public function getName(): string
{
return 'map';
}
public function render( array $attrs ): string
{
$address = $this->attr( $attrs, 'address', '' );
$zoom = $this->attr( $attrs, 'zoom', 15 );
$height = $this->attr( $attrs, 'height', 400 );
if (empty( $address )) {
return '<p class="alert alert-warning">Map widget requires an address</p>';
}
$encodedAddress = urlencode( $address );
return "
<div class='map-widget mb-4'>
<iframe
src='https://maps.google.com/maps?q={$encodedAddress}&z={$zoom}&output=embed'
width='100%'
height='{$height}'
frameborder='0'
style='border:0;'
allowfullscreen>
</iframe>
</div>
";
}
public function getDescription(): string
{
return 'Embeds a Google Map with the specified address';
}
public function getAttributes(): array
{
return [
'address' => 'Street address to display',
'zoom' => 'Zoom level (1-20, default: 15)',
'height' => 'Map height in pixels (default: 400)'
];
}
}
In your application initializer or bootstrap file:
use Neuron\Patterns\Registry;
use App\Widgets\MapWidget;
$widgetRegistry = Registry::getInstance()->get( 'WidgetRegistry' );
$widgetRegistry->register( new MapWidget());
[map address="1600 Amphitheatre Parkway, Mountain View, CA" zoom="14"]
The Widget base class provides helper methods:
attr($attrs, $key, $default = null)
sanitizeHtml($html)
view($template, $data = [])
<?php
namespace App\Widgets;
use Neuron\Cms\Services\Widget\Widget;
use Neuron\Cms\Repositories\IPostRepository;
class FeaturedPostsWidget extends Widget
{
private IPostRepository $postRepository;
public function __construct( IPostRepository $postRepository )
{
$this->postRepository = $postRepository;
}
public function getName(): string
{
return 'featured-posts';
}
public function render( array $attrs ): string
{
$limit = (int) $this->attr( $attrs, 'limit', 3 );
$category = $this->attr( $attrs, 'category' );
// Get posts
if( $category )
{
$posts = $this->postRepository->getByCategory( $category, $limit );
}
else {
$posts = $this->postRepository->getPublished( $limit );
}
if (empty( $posts )) {
return '';
}
$html = '<div class="featured-posts row">';
foreach( $posts as $post )
{
$title = htmlspecialchars( $post->getTitle());
$excerpt = htmlspecialchars( $post->getExcerpt() ?? '');
$url = route_path( 'blog_post', ['slug' => $post->getSlug()]);
$image = $post->getFeaturedImage()
? htmlspecialchars( $post->getFeaturedImage())
: '/images/placeholder.jpg';
$html .= "
<div class='col-md-4 mb-4'>
<div class='card h-100'>
<img src='{$image}' class='card-img-top' alt='{$title}'>
<div class='card-body'>
<h3 class='card-title h5'>{$title}</h3>
<p class='card-text'>{$excerpt}</p>
<a href='{$url}' class='btn btn-primary'>Read More</a>
</div>
</div>
</div>
";
}
$html .= '</div>';
return $html;
}
public function getDescription(): string
{
return 'Displays featured blog posts in a card grid';
}
public function getAttributes(): array
{
return [
'limit' => 'Number of posts to display (default: 3)',
'category' => 'Filter by category slug (optional)'
];
}
}
Location: Neuron\Cms\Services\Widget\WidgetRegistry
Methods:
// Register a widget
$registry->register( IWidget $widget ): void
// Get widget by name
$widget = $registry->get( string $name ): ?IWidget
// Check if widget exists
$exists = $registry->has( string $name ): bool
// Get all registered widgets
$widgets = $registry->all(): array
Location: Neuron\Cms\Services\Content\ShortcodeParser
Custom Handlers:
You can register custom shortcode handlers without creating a full widget:
use Neuron\Cms\Services\Content\ShortcodeParser;
$parser = new ShortcodeParser();
$parser->register( 'youtube', function($attrs ) {
$videoId = $attrs['id'] ?? '';
if (empty( $videoId )) {
return '';
}
return "
<div class='ratio ratio-16x9'>
<iframe src='https://www.youtube.com/embed/{$videoId}' allowfullscreen></iframe>
</div>
";
});
// Use: [youtube id="dQw4w9WgXcQ"]
Both posts and pages auto-generate SEO-friendly slugs from titles:
Algorithm:
Non-ASCII Fallback:
For titles containing only non-ASCII characters (e.g., Chinese "你好", Arabic "مرحبا", Russian "Привет"), the normal slug generation would produce an empty string. To prevent routing errors and database constraint violations, the system generates a unique fallback slug using uniqid():
page-{uniqueid} (e.g., page-65a3b2c1f4e8d)post-{uniqueid} (e.g., post-65a3b2c1f4e8d)category-{uniqueid} (e.g., category-65a3b2c1f4e8d)tag-{uniqueid} (e.g., tag-65a3b2c1f4e8d)Examples:
| Title | Generated Slug |
|---|---|
| "Hello World" | hello-world |
| "Getting Started Guide" | getting-started-guide |
| "10 Tips & Tricks!" | 10-tips-tricks |
| "你好世界" (Chinese) | page-65a3b2c1f4e8d (fallback) |
| "مرحبا بك" (Arabic) | post-65a3b2c1f4e8d (fallback) |
| "Hello 你好 World" | hello-world (mixed, keeps ASCII) |
Custom Slugs:
You can override auto-generated slugs by providing a custom slug when creating or updating content. Custom slugs are not validated for ASCII-only content, so ensure they are URL-safe.
Posts:
Pages:
Generate XML sitemap with all published content:
$posts = $postRepository->getPublished( 1000 );
$pages = $pageRepository->getPublished( 1000 );
// Generate sitemap XML...
Always use absolute URLs for canonical tags:
<link rel="canonical" href="<?= $siteUrl ?>/blog/post/<?= $post->getSlug() ?>">
Future Enhancement: Content versioning is not yet implemented but could include:
Configure upload endpoint for Editor.js image tool:
// In Admin controller
public function uploadImage( Request $request ): string
{
$file = $request->files( 'image' );
// Validate
if (!in_array( $file['type'], ['image/jpeg', 'image/png', 'image/gif'] )) {
return json_encode( ['success' => 0, 'message' => 'Invalid file type'] );
}
// Save to storage
$filename = uniqid( 'img_' ) . '.' . pathinfo( $file['name'], PATHINFO_EXTENSION );
$path = '/uploads/' . date( 'Y/m/' ) . $filename;
move_uploaded_file( $file['tmp_name'], PUBLIC_PATH . $path );
return json_encode( [
'success' => 1,
'file' => [
'url' => $path
]
] );
}
When embedding server-side JSON data in JavaScript (e.g., loading Editor.js content), always use proper escaping to prevent XSS vulnerabilities and syntax errors:
❌ Unsafe (DO NOT DO THIS):
<script>
const existingContent = <?= $page->getContentRaw() ?>; // DANGEROUS!
</script>
Problems with unsafe approach:
</script>, it breaks out of the script tag (XSS)✅ Safe Approach:
<script>
// 1. Encode as JavaScript string with HTML hex flags
const existingContentJson = <?= json_encode( $page->getContentRaw(),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?>;
// 2. Parse on client side with error handling
let existingContent;
try {
existingContent = JSON.parse( existingContentJson );
} catch( error )
{
console.error( 'Failed to parse existing content:', error );
existingContent = { blocks: [] }; // Fallback
}
// 3. Use safely parsed data
const editor = new EditorJS( {
data: existingContent,
// ... other config
} );
</script>
Why this is safe:
JSON_HEX_TAG escapes < and > to prevent script tag injectionJSON_HEX_AMP escapes & to prevent entity issuesJSON_HEX_APOS and JSON_HEX_QUOT escape quotesJSON.parse() with try/catch handles malformed data gracefullyAlways sanitize user-generated HTML content:
use Neuron\Cms\Services\Content\EditorJsRenderer;
$renderer = new EditorJsRenderer( $shortcodeParser );
$html = $renderer->render( $page->getContent());
// The EditorJsRenderer automatically sanitizes HTML by:
// 1. Escaping user input with htmlspecialchars()
// 2. Allowing only safe HTML tags in specific blocks
// 3. Stripping dangerous attributes (onclick, onerror, etc.)
// 4. Validating header levels (clamped to 1-6 range)
When accepting structured data (like Editor.js blocks), always validate values before using them in HTML:
❌ Unsafe - Direct Interpolation:
$level = $data['level'] ?? 2;
return "<h{$level}>text</h{$level}>"; // DANGEROUS!
Problems:
"2 onclick='alert(1)'" creates: <h2 onclick='alert(1)'>text</h2 onclick='alert(1)'>✅ Safe - Validated Integer:
// Coerce to int and clamp to valid range
$rawLevel = $data['level'] ?? 2;
$level = max( 1, min(6, intval($rawLevel )));
return "<h{$level}>text</h{$level}>"; // SAFE!
How it works:
intval() converts any value to integer (strips malicious content)max(1, ...) ensures minimum value is 1min(6, ...) ensures maximum value is 6Always use parameterized queries (already enforced by repositories):
// ✅ Safe - parameterized query
$stmt = $pdo->prepare( 'SELECT * FROM pages WHERE slug = :slug' );
$stmt->execute( ['slug' => $slug] );
// ❌ Unsafe - DO NOT DO THIS
$stmt = $pdo->query( "SELECT * FROM pages WHERE slug = '$slug'" );
All state-changing forms include CSRF tokens (already implemented):
<form method="POST" action="<?= route_path( 'admin_pages_store' ) ?>">
<?= csrf_field() ?> <!-- REQUIRED for POST/PUT/DELETE -->
<!-- ... form fields ... -->
</form>
Always verify user permissions in controllers:
// Check if user can edit this page
if ($page->getAuthorId() !== $user->getId() && !$user->isAdmin()) {
$this->redirect( 'admin_pages', [], ['error', 'Unauthorized'] );
}
Issue: Page returns 404 Solution:
/pages/{slug}Issue: Shortcode displays as text [latest-posts]
Solution:
Issue: Content disappears after save Solution:
content-json hidden input is populatedIssue: Cannot create page/post with existing slug Solution:
Issue: Cannot delete user who authored content Solution:
Enable view caching for pages:
// In config
'cache' => [
'enabled' => true,
'ttl' => 3600, // 1 hour
'storage' => 'file',
'path' => 'storage/cache/views'
]
Avoid N+1 queries by eager loading relationships:
// In PostRepository
$posts = $this->findAllWithRelations( ['author', 'categories', 'tags'] );
All high-frequency queries have indexes:
slug (unique)statuspublished_atauthor_id