The Neuron CMS is designed for extensibility through controller inheritance, service composition, event listeners, view template customization, and middleware filters. This guide demonstrates common customization patterns and best practices for extending the CMS to meet specific requirements.
The CMS follows these design principles:
All CMS controllers inherit from Neuron\Mvc\Controllers\Base, which provides:
Create a new controller by extending the base controller:
<?php
namespace App\Controllers;
use Neuron\Mvc\Controllers\Base;
use Neuron\Mvc\Requests\Request;
use Neuron\Mvc\Responses\HttpResponseStatus;
class ProductsController extends Base
{
public function index( Request $request ): string
{
$products = $this->getProducts();
return $this->renderHtml( HttpResponseStatus::OK,
[
'products' => $products,
'title' => 'Products'
],
'products/index' );
}
public function show( Request $request ): string
{
$id = $request->getRouteParameter( 'id' );
$product = $this->getProductById( $id );
if( !$product )
{
throw new \Neuron\Core\Exceptions\NotFound( "Product not found" );
}
return $this->renderHtml( HttpResponseStatus::OK,
[
'product' => $product,
'title' => $product->getName()
],
'products/show' );
}
private function getProducts(): array
{
// Retrieve products from repository
return [];
}
private function getProductById( int $id ): ?object
{
// Retrieve product from repository
return null;
}
}
Extend existing CMS controllers to customize behavior:
<?php
namespace App\Controllers\Admin;
use Neuron\Cms\Controllers\Admin\Posts as BasePostsController;
use Neuron\Mvc\Requests\Request;
class Posts extends BasePostsController
{
/**
* Override index to add custom filtering
*/
public function index( Request $request ): string
{
// Add custom logic before base implementation
$customFilter = $request->getParameter( 'custom_filter' );
// Call parent implementation
$result = parent::index( $request );
// Or completely override
return $this->renderHtml( \Neuron\Mvc\Responses\HttpResponseStatus::OK,
[
'posts' => $this->getCustomFilteredPosts($customFilter ),
'title' => 'Custom Posts View'
],
'admin/posts/custom-index' );
}
/**
* Add new custom action
*/
public function export( Request $request ): string
{
$posts = $this->postRepository->findAll();
return $this->renderJson( \Neuron\Mvc\Responses\HttpResponseStatus::OK,
['posts' => array_map(fn($p ) => $p->toArray(), $posts)] );
}
}
Controllers have access to multiple rendering methods:
// HTML response
return $this->renderHtml( HttpResponseStatus::OK, $data, 'template/path' );
// JSON response
return $this->renderJson( HttpResponseStatus::OK, ['key' => 'value'] );
// XML response
return $this->renderXml( HttpResponseStatus::OK, $data, 'root_element' );
// Markdown response
return $this->renderMarkdown( HttpResponseStatus::OK, $data, 'template/path' );
// Redirect
return $this->redirect( '/destination/path' );
// HTTP status without body
return $this->renderHttpStatus( HttpResponseStatus::NO_CONTENT );
Services encapsulate business logic and are reusable across controllers.
<?php
namespace App\Services\Product;
use Neuron\Data\Settings\Manager;
use App\Repositories\IProductRepository;
use Neuron\Log\Log;
class ProductManager
{
private IProductRepository $productRepository;
private Manager $settingManager;
public function __construct( IProductRepository $productRepository,
Manager $settingManager ) {
$this->productRepository = $productRepository;
$this->settingManager = $settingManager;
}
public function createProduct( array $data ): Product
{
// Validate data
$this->validateProductData( $data );
// Create product
$product = new Product();
$product->setName( $data['name'] );
$product->setPrice( $data['price'] );
$product->setDescription( $data['description'] );
// Save to repository
$this->productRepository->save( $product );
// Emit event
\Neuron\Application\CrossCutting\Event::emit( new \App\Events\ProductCreated( $product ) );
Log::info( "Product created: {$product->getId()}");
return $product;
}
public function updateProduct( int $id, array $data ): Product
{
$product = $this->productRepository->findById( $id );
if( !$product )
{
throw new \Neuron\Core\Exceptions\NotFound( "Product not found" );
}
// Update fields
if (isset( $data['name'] )) {
$product->setName( $data['name'] );
}
if (isset( $data['price'] )) {
$product->setPrice( $data['price'] );
}
// Save changes
$this->productRepository->update( $product );
// Emit event
\Neuron\Application\CrossCutting\Event::emit( new \App\Events\ProductUpdated( $product ) );
return $product;
}
private function validateProductData( array $data ): void
{
$required = ['name', 'price'];
foreach( $required as $field )
{
if (empty( $data[$field] )) {
throw new \InvalidArgumentException( "Field '{$field}' is required" );
}
}
}
}
Instantiate services in controllers using dependency injection:
<?php
namespace App\Controllers\Admin;
use Neuron\Mvc\Controllers\Base;
use Neuron\Mvc\Requests\Request;
use App\Services\Product\ProductManager;
use App\Repositories\DatabaseProductRepository;
class Products extends Base
{
private ProductManager $productManager;
public function __construct()
{
parent::__construct();
// Instantiate dependencies
$productRepository = new DatabaseProductRepository( $this->getSettingManager() );
$this->productManager = new ProductManager( $productRepository,
$this->getSettingManager() );
}
public function create( Request $request ): string
{
if ($request->getMethod() === 'POST') {
try {
$product = $this->productManager->createProduct( [
'name' => $request->getParameter('name' ),
'price' => $request->getParameter( 'price' ),
'description' => $request->getParameter( 'description' )
]);
return $this->redirect( "/admin/products/{$product->getId()}");
} catch( \Exception $e )
{
return $this->renderHtml( HttpResponseStatus::BAD_REQUEST,
['error' => $e->getMessage()],
'admin/products/create' );
}
}
return $this->renderHtml( HttpResponseStatus::OK,
[],
'admin/products/create' );
}
}
The CMS uses an event-driven architecture for decoupling components.
The CMS emits these built-in events:
Neuron\Mvc\Events\Http404): Triggered when a resource is not foundNeuron\Mvc\Events\Http500): Triggered on unhandled exceptionsNeuron\Mvc\Events\RequestReceivedEvent): Triggered on each HTTP request<?php
namespace App\Events;
use Neuron\Events\IEvent;
use App\Models\Product;
class ProductCreated implements IEvent
{
private Product $product;
private \DateTimeImmutable $createdAt;
public function __construct( Product $product )
{
$this->product = $product;
$this->createdAt = new \DateTimeImmutable();
}
public function getProduct(): Product
{
return $this->product;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getName(): string
{
return 'product.created';
}
public function getPayload(): array
{
return [
'product_id' => $this->product->getId(),
'product_name' => $this->product->getName(),
'created_at' => $this->createdAt->format( 'Y-m-d H:i:s' )
];
}
}
<?php
namespace App\Listeners;
use Neuron\Events\IListener;
use Neuron\Events\IEvent;
use App\Events\ProductCreated;
use Neuron\Log\Log;
class SendProductCreatedNotification implements IListener
{
public function handle( IEvent $event ): void
{
if( !$event instanceof ProductCreated )
{
return;
}
$product = $event->getProduct();
Log::info( "Sending notification for new product: {$product->getName()}");
// Queue email notification
dispatch( new \App\Jobs\SendProductNotificationJob(), [
'product_id' => $product->getId(),
'admin_emails' => $this->getAdminEmails()
], 'emails');
}
private function getAdminEmails(): array
{
// Retrieve admin emails
return ['[email protected]'];
}
}
Create config/events.yaml:
events:
product.created:
- App\Listeners\SendProductCreatedNotification
- App\Listeners\LogProductCreation
- App\Listeners\UpdateInventoryCache
user.registered:
- App\Listeners\SendWelcomeEmail
- App\Listeners\CreateDefaultSettings
post.published:
- App\Listeners\ClearPostCache
- App\Listeners\NotifySubscribers
use Neuron\Application\CrossCutting\Event;
use App\Events\ProductCreated;
// Emit event
Event::emit( new ProductCreated( $product ));
// Event is automatically dispatched to all registered listeners
Views are located in resources/views/:
resources/views/
├── layouts/
│ ├── main.html.php # Main layout
│ └── admin.html.php # Admin layout
├── partials/
│ ├── header.html.php # Header partial
│ ├── footer.html.php # Footer partial
│ └── nav.html.php # Navigation partial
├── admin/
│ ├── posts/
│ │ ├── index.html.php
│ │ ├── create.html.php
│ │ └── edit.html.php
│ └── users/
│ └── index.html.php
└── products/
├── index.html.php
└── show.html.php
Create resources/views/products/index.html.php:
<?php
/**
* @var array $products
* @var string $title
*/
?>
<!DOCTYPE html>
<html>
<head>
<title><?= htmlspecialchars( $title ) ?></title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<?php include __DIR__ . '/../partials/header.html.php'; ?>
<main class="container">
<h1><?= htmlspecialchars( $title ) ?></h1>
<div class="products-grid">
<?php foreach ($products as $product): ?>
<div class="product-card">
<h2><?= htmlspecialchars( $product->getName()) ?></h2>
<p class="price">$<?= number_format( $product->getPrice(), 2) ?></p>
<p><?= htmlspecialchars( $product->getDescription()) ?></p>
<a href="/products/<?= $product->getId() ?>" class="btn">View Details</a>
</div>
<?php endforeach; ?>
</div>
</main>
<?php include __DIR__ . '/../partials/footer.html.php'; ?>
</body>
</html>
Create a layout in resources/views/layouts/product.html.php:
<!DOCTYPE html>
<html>
<head>
<title><?= $title ?? 'Products' ?></title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<?php include __DIR__ . '/../partials/header.html.php'; ?>
<main class="container">
<?= $content ?>
</main>
<?php include __DIR__ . '/../partials/footer.html.php'; ?>
<script src="/js/app.js"></script>
</body>
</html>
Use the layout in views:
<?php
$content = <<<HTML
<h1>{$title}</h1>
<div class="products-grid">
<!-- Product content -->
</div>
HTML;
include __DIR__ . '/../layouts/product.html.php';
To customize CMS templates, copy them to your application's views directory:
# Copy CMS template to your application
cp vendor/neuron-php/cms/resources/views/admin/posts/index.html.php \
resources/views/admin/posts/index.html.php
# Edit the copied file to customize
The MVC component prioritizes application views over vendor views.
Define routes using PHP 8+ attributes directly on controller methods:
<?php
namespace App\Controllers;
use Neuron\Mvc\Controller;
use Neuron\Mvc\Request;
use Neuron\Routing\Attributes\{Get, Post};
class Products extends Controller
{
// Public product routes
#[Get('/products', name: 'products.index')]
public function index(Request $request): string
{
$products = $this->productRepository->all();
return $this->renderHtml(OK, ['products' => $products], 'products/index');
}
#[Get('/products/:id', name: 'products.show')]
public function show(Request $request): string
{
$id = $request->getRouteParameter('id');
$product = $this->productRepository->findById($id);
return $this->renderHtml(OK, ['product' => $product], 'products/show');
}
// Wildcard route for dynamic content
#[Get('/products/category/*slug', name: 'products.category')]
public function category(Request $request): string
{
$slug = $request->getRouteParameter('slug');
// slug can be: "electronics/computers/laptops"
return $this->renderHtml(OK, ['slug' => $slug], 'products/category');
}
}
<?php
namespace App\Controllers\Admin;
use Neuron\Mvc\Controller;
use Neuron\Mvc\Request;
use Neuron\Routing\Attributes\{Get, Post};
class Products extends Controller
{
#[Get('/admin/products/create', name: 'admin.products.create', filters: ['auth'])]
public function create(Request $request): string
{
return $this->renderHtml(OK, [], 'admin/products/create');
}
#[Post('/admin/products', name: 'admin.products.store', filters: ['auth', 'csrf'])]
public function store(Request $request): never
{
// Create product
$this->redirect('admin.products.index', [], ['success', 'Product created']);
}
}
<?php
namespace App\Controllers\Api;
use Neuron\Mvc\Controller;
use Neuron\Mvc\Request;
use Neuron\Routing\Attributes\Get;
class Products extends Controller
{
#[Get('/api/products', name: 'api.products.index', filters: ['api_limit'])]
public function index(Request $request): string
{
$products = $this->productRepository->all();
return $this->renderJson(OK, ['products' => $products]);
}
#[Get('/api/products/:id', name: 'api.products.show', filters: ['api_limit'])]
public function show(Request $request): string
{
$id = $request->getRouteParameter('id');
$product = $this->productRepository->findById($id);
return $this->renderJson(OK, ['product' => $product]);
}
}
Add your controllers to config/routing.yaml so they're scanned for routes:
controller_paths:
# Your application controllers (checked first)
- path: 'app/Controllers'
namespace: 'App\Controllers'
# CMS controllers (checked second)
- path: 'vendor/neuron-php/cms/src/Cms/Controllers'
namespace: 'Neuron\Cms\Controllers'
Access route parameters in controllers:
public function show(Request $request): string
{
// Single parameter
$id = $request->getRouteParameter('id');
// Wildcard parameter (captures all remaining segments)
$slug = $request->getRouteParameter('slug');
// For URL: /products/category/electronics/computers
// $slug = 'electronics/computers'
// Multiple parameters
$userId = $request->getRouteParameter('userId');
$postId = $request->getRouteParameter('postId');
// Validate and typecast
if (!is_numeric($id)) {
throw new NotFound('Invalid ID');
}
$id = (int) $id;
}
Apply filters using the filters parameter in route attributes:
// Single filter - authentication
#[Get('/admin/products', name: 'admin.products.index', filters: ['auth'])]
public function index(Request $request): string { }
// Multiple filters - authentication and CSRF
#[Post('/admin/products/:id', name: 'admin.products.update', filters: ['auth', 'csrf'])]
public function update(Request $request): never { }
// Custom filter
#[Get('/products/premium', name: 'products.premium', filters: ['premium_access'])]
public function premium(Request $request): string { }
## Database Schema Extensions
### Creating Migrations
Generate a migration for custom tables:
```bash
./vendor/bin/neuron db:migration:generate CreateProductsTable
Edit the generated migration in db/migrate/:
<?php
use Phinx\Migration\AbstractMigration;
class CreateProductsTable extends AbstractMigration
{
public function change()
{
$table = $this->table( 'products' );
$table->addColumn( 'name', 'string', ['limit' => 255] )
->addColumn( 'slug', 'string', ['limit' => 255] )
->addColumn( 'description', 'text', ['null' => true] )
->addColumn( 'price', 'decimal', ['precision' => 10, 'scale' => 2] )
->addColumn( 'stock_quantity', 'integer', ['default' => 0] )
->addColumn( 'category_id', 'integer', ['null' => true] )
->addColumn( 'is_active', 'boolean', ['default' => true] )
->addColumn( 'created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP'] )
->addColumn( 'updated_at', 'timestamp', [
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP'
] )
->addIndex( ['slug'], ['unique' => true] )
->addIndex( ['category_id'] )
->addIndex( ['is_active'] )
->addForeignKey( 'category_id', 'categories', 'id', [
'delete' => 'SET_NULL',
'update' => 'CASCADE'
] )
->create();
}
}
Run the migration:
./vendor/bin/neuron db:migrate
Create a migration to add columns to CMS tables:
./vendor/bin/neuron db:migration:generate AddCustomFieldsToUsers
<?php
use Phinx\Migration\AbstractMigration;
class AddCustomFieldsToUsers extends AbstractMigration
{
public function change()
{
$table = $this->table( 'users' );
$table->addColumn( 'company', 'string', ['limit' => 255, 'null' => true] )
->addColumn( 'phone', 'string', ['limit' => 20, 'null' => true] )
->addColumn( 'avatar_url', 'string', ['limit' => 500, 'null' => true] )
->addColumn( 'bio', 'text', ['null' => true] )
->addColumn( 'preferences', 'json', ['null' => true] )
->addIndex( ['company'] )
->update();
}
}
Create custom validation rules by extending the validation component:
<?php
namespace App\Validation;
use Neuron\Validation\IValidator;
class UniqueProductSlugValidator implements IValidator
{
private \PDO $db;
private ?int $excludeId;
public function __construct( \PDO $db, ?int $excludeId = null )
{
$this->db = $db;
$this->excludeId = $excludeId;
}
public function isValid( $value ): bool
{
if (empty( $value )) {
return true; // Let required validator handle empty values
}
$sql = "SELECT COUNT( * ) FROM products WHERE slug = :slug";
if( $this->excludeId !== null )
{
$sql .= " AND id != :exclude_id";
}
$stmt = $this->db->prepare( $sql );
$stmt->bindValue( ':slug', $value );
if( $this->excludeId !== null )
{
$stmt->bindValue( ':exclude_id', $this->excludeId );
}
$stmt->execute();
$count = $stmt->fetchColumn();
return $count == 0;
}
public function getError(): string
{
return "A product with this slug already exists";
}
}
use Neuron\Validation\Policy;
use App\Validation\UniqueProductSlugValidator;
// Create validation policy
$policy = new Policy();
$policy->add( 'name', 'required', 'Product name is required' );
$policy->add( 'name', 'min_length', 'Product name must be at least 3 characters', ['min' => 3] );
$policy->add( 'slug', 'required', 'Slug is required' );
$policy->add( 'slug', 'regex', 'Slug must be URL-friendly', [
'pattern' => '/^[a-z0-9-]+$/'
] );
// Add custom validator
$policy->addValidator( 'slug',
new UniqueProductSlugValidator( $this->getDb(), $productId),
'slug'
);
$policy->add( 'price', 'required', 'Price is required' );
$policy->add( 'price', 'is_float', 'Price must be a valid number' );
$policy->add( 'price', 'min', 'Price must be greater than 0', ['min' => 0.01] );
// Validate
if (!$policy->validate( $data )) {
$errors = $policy->getErrors();
// Handle validation errors
}
Filters (middleware) intercept requests before they reach controllers:
<?php
namespace App\Filters;
use Neuron\Routing\Filters\IFilter;
use Neuron\Routing\Route;
use Neuron\Mvc\Responses\HttpResponseStatus;
class PremiumAccessFilter implements IFilter
{
public function before( Route $route ): bool
{
// Check if user has premium access
$user = auth();
if (!$user || !$user->hasPremiumAccess()) {
http_response_code( HttpResponseStatus::FORBIDDEN );
echo json_encode( ['error' => 'Premium access required'] );
return false; // Block request
}
return true; // Allow request to proceed
}
public function after( Route $route, $output ): string
{
// Modify response if needed
return $output;
}
}
Register filters in your application bootstrap:
use Neuron\Patterns\Registry;
use App\Filters\PremiumAccessFilter;
// Get router
$router = Registry::getInstance()->get( 'Router' );
// Register filter
$router->registerFilter( 'premium_access', new PremiumAccessFilter());
Apply filter to routes using attributes:
class Products extends Controller
{
#[Get('/products/premium', name: 'products.premium', filters: ['premium_access'])]
public function premium(Request $request): string
{
// Premium content
return $this->renderHtml(OK, [], 'products/premium');
}
}
<?php
namespace App\Filters;
use Neuron\Routing\Filters\IFilter;
use Neuron\Routing\Route;
class RateLimitFilter implements IFilter
{
private int $maxRequests;
private int $windowSeconds;
private array $requests = [];
public function __construct( int $maxRequests = 60, int $windowSeconds = 60 )
{
$this->maxRequests = $maxRequests;
$this->windowSeconds = $windowSeconds;
}
public function before( Route $route ): bool
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$now = time();
// Clean old requests
$this->requests[$ip] = array_filter( $this->requests[$ip] ?? [],
fn($timestamp ) => $now - $timestamp < $this->windowSeconds );
// Check rate limit
if (count( $this->requests[$ip] ) >= $this->maxRequests) {
http_response_code( 429 );
header( 'Retry-After: ' . $this->windowSeconds );
echo json_encode( ['error' => 'Rate limit exceeded'] );
return false;
}
// Record request
$this->requests[$ip][] = $now;
return true;
}
public function after( Route $route, $output ): string
{
return $output;
}
}
<?php
namespace App\Repositories;
use App\Models\Product;
interface IProductRepository
{
public function findAll(): array;
public function findById( int $id ): ?Product;
public function findBySlug( string $slug ): ?Product;
public function findByCategory( int $categoryId ): array;
public function save( Product $product ): void;
public function update( Product $product ): void;
public function delete( int $id ): void;
}
<?php
namespace App\Repositories;
use App\Models\Product;
use Neuron\Data\Settings\Manager;
use PDO;
class DatabaseProductRepository implements IProductRepository
{
private PDO $db;
public function __construct( Manager $settingManager )
{
// Initialize database connection
$this->db = $this->createDatabaseConnection( $settingManager );
}
public function findAll(): array
{
$stmt = $this->db->query( "
SELECT * FROM products
WHERE is_active = 1
ORDER BY created_at DESC
" );
$products = [];
while ($row = $stmt->fetch( PDO::FETCH_ASSOC )) {
$products[] = $this->hydrate( $row );
}
return $products;
}
public function findById( int $id ): ?Product
{
$stmt = $this->db->prepare( "SELECT * FROM products WHERE id = :id" );
$stmt->execute( ['id' => $id] );
$row = $stmt->fetch( PDO::FETCH_ASSOC );
return $row ? $this->hydrate( $row ) : null;
}
public function findBySlug( string $slug ): ?Product
{
$stmt = $this->db->prepare( "SELECT * FROM products WHERE slug = :slug" );
$stmt->execute( ['slug' => $slug] );
$row = $stmt->fetch( PDO::FETCH_ASSOC );
return $row ? $this->hydrate( $row ) : null;
}
public function findByCategory( int $categoryId ): array
{
$stmt = $this->db->prepare( "
SELECT * FROM products
WHERE category_id = :category_id AND is_active = 1
ORDER BY name ASC
" );
$stmt->execute( ['category_id' => $categoryId] );
$products = [];
while ($row = $stmt->fetch( PDO::FETCH_ASSOC )) {
$products[] = $this->hydrate( $row );
}
return $products;
}
public function save( Product $product ): void
{
$stmt = $this->db->prepare( "
INSERT INTO products (name, slug, description, price, stock_quantity, category_id, is_active )
VALUES (:name, :slug, :description, :price, :stock, :category_id, :is_active)
");
$stmt->execute( [
'name' => $product->getName(),
'slug' => $product->getSlug(),
'description' => $product->getDescription(),
'price' => $product->getPrice(),
'stock' => $product->getStockQuantity(),
'category_id' => $product->getCategoryId(),
'is_active' => $product->isActive() ? 1 : 0
]);
$product->setId( $this->db->lastInsertId());
}
public function update( Product $product ): void
{
$stmt = $this->db->prepare( "
UPDATE products
SET name = :name,
slug = :slug,
description = :description,
price = :price,
stock_quantity = :stock,
category_id = :category_id,
is_active = :is_active
WHERE id = :id
" );
$stmt->execute( [
'id' => $product->getId(),
'name' => $product->getName(),
'slug' => $product->getSlug(),
'description' => $product->getDescription(),
'price' => $product->getPrice(),
'stock' => $product->getStockQuantity(),
'category_id' => $product->getCategoryId(),
'is_active' => $product->isActive() ? 1 : 0
]);
}
public function delete( int $id ): void
{
$stmt = $this->db->prepare( "DELETE FROM products WHERE id = :id" );
$stmt->execute( ['id' => $id] );
}
private function hydrate( array $row ): Product
{
$product = new Product();
$product->setId( $row['id'] );
$product->setName( $row['name'] );
$product->setSlug( $row['slug'] );
$product->setDescription( $row['description'] );
$product->setPrice( $row['price'] );
$product->setStockQuantity( $row['stock_quantity'] );
$product->setCategoryId( $row['category_id'] );
$product->setIsActive( (bool )$row['is_active']);
$product->setCreatedAt( new \DateTimeImmutable( $row['created_at'] ));
return $product;
}
private function createDatabaseConnection( Manager $settingManager ): PDO
{
// Create PDO connection from settings
// Implementation depends on your database configuration
return new PDO( /* connection parameters */ );
}
}
<?php
namespace App\Models;
class Product
{
private ?int $id = null;
private string $name;
private string $slug;
private ?string $description = null;
private float $price;
private int $stockQuantity = 0;
private ?int $categoryId = null;
private bool $isActive = true;
private ?\DateTimeImmutable $createdAt = null;
// Getters
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getSlug(): string
{
return $this->slug;
}
public function getDescription(): ?string
{
return $this->description;
}
public function getPrice(): float
{
return $this->price;
}
public function getStockQuantity(): int
{
return $this->stockQuantity;
}
public function getCategoryId(): ?int
{
return $this->categoryId;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
// Setters
public function setId( int $id ): void
{
$this->id = $id;
}
public function setName( string $name ): void
{
$this->name = $name;
}
public function setSlug( string $slug ): void
{
$this->slug = $slug;
}
public function setDescription( ?string $description ): void
{
$this->description = $description;
}
public function setPrice( float $price ): void
{
$this->price = $price;
}
public function setStockQuantity( int $quantity ): void
{
$this->stockQuantity = $quantity;
}
public function setCategoryId( ?int $categoryId ): void
{
$this->categoryId = $categoryId;
}
public function setIsActive( bool $isActive ): void
{
$this->isActive = $isActive;
}
public function setCreatedAt( \DateTimeImmutable $createdAt ): void
{
$this->createdAt = $createdAt;
}
// Business logic methods
public function isInStock(): bool
{
return $this->stockQuantity > 0;
}
public function decrementStock( int $quantity ): void
{
if( $quantity > $this->stockQuantity )
{
throw new \RuntimeException( 'Insufficient stock' );
}
$this->stockQuantity -= $quantity;
}
public function incrementStock( int $quantity ): void
{
$this->stockQuantity += $quantity;
}
public function getFormattedPrice(): string
{
return '$' . number_format( $this->price, 2 );
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'price' => $this->price,
'stock_quantity' => $this->stockQuantity,
'category_id' => $this->categoryId,
'is_active' => $this->isActive,
'created_at' => $this->createdAt?->format( 'Y-m-d H:i:s' )
];
}
}
Extend existing CMS models to add custom functionality:
<?php
namespace App\Models;
use Neuron\Cms\Models\User as BaseUser;
class User extends BaseUser
{
private ?string $company = null;
private ?string $phone = null;
private ?string $avatarUrl = null;
private ?string $bio = null;
private array $preferences = [];
// Additional getters
public function getCompany(): ?string
{
return $this->company;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function getAvatarUrl(): ?string
{
return $this->avatarUrl;
}
public function getBio(): ?string
{
return $this->bio;
}
public function getPreferences(): array
{
return $this->preferences;
}
// Additional setters
public function setCompany( ?string $company ): void
{
$this->company = $company;
}
public function setPhone( ?string $phone ): void
{
$this->phone = $phone;
}
public function setAvatarUrl( ?string $avatarUrl ): void
{
$this->avatarUrl = $avatarUrl;
}
public function setBio( ?string $bio ): void
{
$this->bio = $bio;
}
public function setPreferences( array $preferences ): void
{
$this->preferences = $preferences;
}
// Custom business logic
public function hasPremiumAccess(): bool
{
return $this->preferences['premium'] ?? false;
}
public function getPreference( string $key, $default = null )
{
return $this->preferences[$key] ?? $default;
}
public function setPreference( string $key, $value ): void
{
$this->preferences[$key] = $value;
}
// Override toArray to include custom fields
public function toArray(): array
{
return array_merge( parent::toArray(), [
'company' => $this->company,
'phone' => $this->phone,
'avatar_url' => $this->avatarUrl,
'bio' => $this->bio,
'preferences' => $this->preferences
]);
}
}
use Neuron\Patterns\Registry;
// Store service in registry
Registry::getInstance()->set( 'ProductManager', $productManager );
// Retrieve service from registry
$productManager = Registry::getInstance()->get( 'ProductManager' );
class ProductController extends Base
{
private ProductManager $productManager;
private IProductRepository $productRepository;
public function __construct()
{
parent::__construct();
// Inject dependencies via constructor
$this->productRepository = new DatabaseProductRepository( $this->getSettingManager() );
$this->productManager = new ProductManager( $this->productRepository,
$this->getSettingManager() );
}
}
This example demonstrates all customization concepts together.
./vendor/bin/neuron db:migration:generate CreateProductsTable
src/Models/Product.php (see Extended Models section above)
src/Repositories/IProductRepository.php and DatabaseProductRepository.php (see Repository Pattern section above)
src/Services/Product/ProductManager.php (see Creating Custom Services section above)
src/Events/ProductCreated.php (see Event System Integration section above)
src/Listeners/SendProductCreatedNotification.php (see Event System Integration section above)
<?php
namespace App\Controllers\Admin;
use Neuron\Mvc\Controllers\Base;
use Neuron\Mvc\Requests\Request;
use Neuron\Mvc\Responses\HttpResponseStatus;
use App\Services\Product\ProductManager;
use App\Repositories\DatabaseProductRepository;
class Products extends Base
{
private ProductManager $productManager;
public function __construct()
{
parent::__construct();
$repository = new DatabaseProductRepository( $this->getSettingManager());
$this->productManager = new ProductManager( $repository, $this->getSettingManager());
}
public function index( Request $request ): string
{
$products = $this->productManager->getAllProducts();
return $this->renderHtml( HttpResponseStatus::OK,
['products' => $products, 'title' => 'Products'],
'admin/products/index' );
}
public function create( Request $request ): string
{
if ($request->getMethod() === 'POST') {
try {
$product = $this->productManager->createProduct( [
'name' => $request->getParameter('name' ),
'slug' => $request->getParameter( 'slug' ),
'description' => $request->getParameter( 'description' ),
'price' => (float)$request->getParameter( 'price' ),
'stock_quantity' => (int)$request->getParameter( 'stock_quantity' ),
'category_id' => $request->getParameter( 'category_id' )
]);
return $this->redirect( "/admin/products/{$product->getId()}");
} catch( \Exception $e )
{
return $this->renderHtml( HttpResponseStatus::BAD_REQUEST,
['error' => $e->getMessage(), 'title' => 'Create Product'],
'admin/products/create' );
}
}
return $this->renderHtml( HttpResponseStatus::OK,
['title' => 'Create Product'],
'admin/products/create' );
}
}
Add route attributes to your controller methods:
<?php
namespace App\Controllers\Admin;
use Neuron\Mvc\Controller;
use Neuron\Mvc\Request;
use Neuron\Routing\Attributes\{Get, Post};
class Products extends Controller
{
#[Get('/admin/products', name: 'admin.products.index', filters: ['auth'])]
public function index(Request $request): string
{
$products = $this->productManager->getAllProducts();
return $this->renderHtml(OK, ['products' => $products], 'admin/products/index');
}
#[Get('/admin/products/create', name: 'admin.products.create', filters: ['auth'])]
public function create(Request $request): string
{
return $this->renderHtml(OK, [], 'admin/products/create');
}
#[Post('/admin/products', name: 'admin.products.store', filters: ['auth', 'csrf'])]
public function store(Request $request): never
{
// Store product
$this->redirect('admin.products.index', [], ['success', 'Product created']);
}
}
resources/views/admin/products/index.html.php:
<?php include __DIR__ . '/../../layouts/admin.html.php'; ?>
<div class="content">
<h1><?= htmlspecialchars( $title ) ?></h1>
<a href="/admin/products/create" class="btn btn-primary">Create Product</a>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Price</th>
<th>Stock</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($products as $product): ?>
<tr>
<td><?= $product->getId() ?></td>
<td><?= htmlspecialchars( $product->getName()) ?></td>
<td><?= $product->getFormattedPrice() ?></td>
<td><?= $product->getStockQuantity() ?></td>
<td><?= $product->isActive() ? 'Active' : 'Inactive' ?></td>
<td>
<a href="/admin/products/<?= $product->getId() ?>/edit">Edit</a>
<a href="/admin/products/<?= $product->getId() ?>/delete">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
config/events.yaml:
events:
product.created:
- App\Listeners\SendProductCreatedNotification
public function createProduct( array $data ): Product
{
// Type-safe method signatures
}
Always validate user input before processing:
$policy = new Policy();
$policy->add( 'name', 'required', 'Name is required' );
$policy->add( 'email', 'email', 'Invalid email' );
if (!$policy->validate( $data )) {
// Handle validation errors
}
try {
$product = $this->productManager->createProduct( $data );
} catch( \InvalidArgumentException $e )
{
// Handle validation errors
} catch( \RuntimeException $e )
{
// Handle business logic errors
} catch( \Exception $e )
{
// Handle unexpected errors
Log::error( $e->getMessage());
}
Emit events for significant actions to allow other components to react:
Event::emit( new ProductCreated( $product ));
Queue long-running tasks instead of executing synchronously:
dispatch( new ProcessProductImportJob(), ['file' => $path], 'processing');
Create unit tests for services and integration tests for controllers:
class ProductManagerTest extends TestCase
{
public function testCreateProduct(): void
{
$manager = new ProductManager( $mockRepository, $mockSettings );
$product = $manager->createProduct( ['name' => 'Test Product'] );
$this->assertInstanceOf( Product::class, $product );
$this->assertEquals( 'Test Product', $product->getName());
}
}
Problem: Router cannot find controller class
Solutions:
composer dump-autoloadProblem: View template cannot be located
Solutions:
renderHtml() callresources/views/.html.phpProblem: Cannot connect to database
Solutions:
config/neuron.yamlProblem: Event listeners not executing
Solutions:
config/events.yamlIListenerhandle() method signature is correctProblem: Service cannot be retrieved from Registry
Solutions: