Customization Guide

Overview

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.

Architecture Principles

The CMS follows these design principles:

Extending Controllers

Controller Hierarchy

All CMS controllers inherit from Neuron\Mvc\Controllers\Base, which provides:

Creating a Custom Controller

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;
    }
}

Extending CMS Controllers

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)] );
    }
}

Response Methods

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 );

Creating Custom Services

Services encapsulate business logic and are reusable across controllers.

Service Structure

<?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" );
            }
        }
    }
}

Using Services in Controllers

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' );
    }
}

Event System Integration

The CMS uses an event-driven architecture for decoupling components.

Available CMS Events

The CMS emits these built-in events:

Creating Custom Events

<?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' )
        ];
    }
}

Creating Event Listeners

<?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]'];
    }
}

Registering Event Listeners

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

Emitting Events

use Neuron\Application\CrossCutting\Event;
use App\Events\ProductCreated;

// Emit event
Event::emit( new ProductCreated( $product ));

// Event is automatically dispatched to all registered listeners

View Template Customization

Template Structure

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

Creating Custom Templates

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>

Using Layouts

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';

Overriding CMS Templates

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.

Custom Routes

Adding Routes

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');
    }
}

Admin Routes with Filters

<?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']);
    }
}

API Routes

<?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]);
    }
}

Registering Controllers

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'

Route Parameters

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;
}

Route Filters

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

Extending Existing Tables

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();
    }
}

Custom Validators

Creating Validation Rules

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";
    }
}

Using Custom Validators

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
}

Middleware Development

Creating Custom Filters

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;
    }
}

Registering Filters

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');
    }
}

Rate Limiting Filter

<?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;
    }
}

Repository Pattern

Creating Repository Interfaces

<?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;
}

Implementing Repositories

<?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 */ );
    }
}

Extending Models

Creating Custom Models

<?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' )
        ];
    }
}

Extending CMS Models

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
        ]);
    }
}

Dependency Injection

Using Registry for Service Location

use Neuron\Patterns\Registry;

// Store service in registry
Registry::getInstance()->set( 'ProductManager', $productManager );

// Retrieve service from registry
$productManager = Registry::getInstance()->get( 'ProductManager' );

Constructor Injection Pattern

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() );
    }
}

Complete Example: E-commerce Product Module

This example demonstrates all customization concepts together.

1. Create Migration

./vendor/bin/neuron db:migration:generate CreateProductsTable

2. Define Model

src/Models/Product.php (see Extended Models section above)

3. Create Repository

src/Repositories/IProductRepository.php and DatabaseProductRepository.php (see Repository Pattern section above)

4. Create Service

src/Services/Product/ProductManager.php (see Creating Custom Services section above)

5. Create Event

src/Events/ProductCreated.php (see Event System Integration section above)

6. Create Listener

src/Listeners/SendProductCreatedNotification.php (see Event System Integration section above)

7. Create Controller

<?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' );
    }
}

8. Define Routes

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']);
    }
}

9. Create View Template

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>

10. Register Event Listener

config/events.yaml:

events:
  product.created:
    - App\Listeners\SendProductCreatedNotification

Best Practices

1. Follow SOLID Principles

2. Use Type Declarations

public function createProduct( array $data ): Product
{
    // Type-safe method signatures
}

3. Validate Input

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
}

4. Handle Exceptions Properly

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());
}

5. Use Events for Decoupling

Emit events for significant actions to allow other components to react:

Event::emit( new ProductCreated( $product ));

6. Leverage Background Jobs

Queue long-running tasks instead of executing synchronously:

dispatch( new ProcessProductImportJob(), ['file' => $path], 'processing');

7. Write Tests

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());
    }
}

Troubleshooting

Controller Not Found

Problem: Router cannot find controller class

Solutions:

  1. Verify class namespace matches route configuration
  2. Ensure PSR-4 autoloading is configured correctly
  3. Run composer dump-autoload
  4. Check class file exists at correct path

Template Not Found

Problem: View template cannot be located

Solutions:

  1. Verify template path matches renderHtml() call
  2. Check file exists in resources/views/
  3. Ensure file extension is .html.php
  4. Verify Views.Path is configured in Registry

Database Connection Errors

Problem: Cannot connect to database

Solutions:

  1. Verify database configuration in config/neuron.yaml
  2. Check database credentials
  3. Ensure database server is running
  4. Verify network connectivity to database server

Event Listeners Not Firing

Problem: Event listeners not executing

Solutions:

  1. Verify listener is registered in config/events.yaml
  2. Check event name matches in emit and configuration
  3. Ensure listener class implements IListener
  4. Verify listener handle() method signature is correct

Service Not Available

Problem: Service cannot be retrieved from Registry

Solutions:

  1. Verify service is registered in Registry before use
  2. Check Registry key name is correct
  3. Ensure service is instantiated before retrieval
  4. Consider using dependency injection instead of Registry

Additional Resources