v1.0
Docs / Modules

Modules

Modules are self-contained feature packages that group related code together. This page explains how to create, organize, and use modules effectively.

Project Minimum Requirements

RequirementVersionNotes
PHP≥ 8.1Required for typed properties, enums, and match expressions
CodeIgniter≥ 4.0Framework foundation
MySQL≥ 8.0Required for JSON functions (JSON_CONTAINS)
Redis≥ 6.0For caching, queues, and real-time metrics
Composer≥ 2.0Dependency management

Optional Dependencies

PackagePurpose
dompdf/dompdfPDF export functionality
phpoffice/phpspreadsheetExcel export functionality

1. The Concept

Goal: Understand why we group code by "Feature" (User, Order) instead of "Type" (Controllers, Models).
  1. What Are Modules?
  2. Existing Modules
  3. Module Structure (Detailed)
  4. Creating a Module (Step-by-Step)
  5. Module Services vs Domain Services
  6. Best Practices
  7. Database Schema Reference (Full Tables List)
  8. Docker Verification
  9. Module Features Example: Analytics with D3 Charts

1.1 What Are Modules?

Technical Definition

A Module is a self-contained directory that groups all code related to a specific feature area. Each module has its own:

Simple Analogy

Think of modules like departments in a mall:

Mall DepartmentCode ModuleContains
Electronics StoreAdmin ModuleDashboard, User Management, Settings
Food CourtWeb ModuleHome, About, Contact, Blog
Customer ServiceMember ModuleProfile, Orders, Support
Warehouse (back-end)Api ModuleREST endpoints, Webhooks

Each department operates independently but shares the same building (the app).

Without Modules vs With Modules

WITHOUT MODULES: WITH MODULES: Controllers/ Modules/ ├── AdminDashboardController.php ├── Admin/ ├── AdminUserController.php │ ├── Dashboard/ ├── AdminReportController.php │ │ └── Controllers/DashboardController.php ├── HomeController.php │ ├── User/ ├── AboutController.php │ │ └── Controllers/UserController.php ├── ContactController.php │ └── Shared/Layouts/admin.php ├── ApiOrderController.php │ ├── ApiProductController.php ├── Web/ ├── MemberProfileController.php │ ├── Home/Controllers/HomeController.php ├── MemberOrderController.php │ └── Shared/Layouts/public.php └── (50+ more controllers...) │ ├── Member/ Views/ │ └── Account/Controllers/... ├── admin/ │ │ ├── dashboard/ └── Api/ │ ├── users/ └── Order/Controllers/... │ └── reports/ ├── home/ Each module is self-contained! ├── about/ └── (100+ view files...)

1.2 Existing Modules

ModulePathPurposeURL Pattern
AdminModules/Admin/Internal admin panel for staff/admin/*
WebModules/Web/Public-facing website/*
MemberModules/Member/Logged-in member area/member/*
ApiModules/Api/REST API for integrations (Protected)/api/*
MapsModules/Admin/Maps/Interactive Maps & Navigation/admin/maps/*

API Module Security

The Api Module is protected with a comprehensive security layer using PHP Attributes. This enables declarative, NestJS-style Guards for authentication and rate limiting.

Security Attributes

AttributeLocationPurpose
#[ApiAuth]App\Attributes\ApiAuthEnforces authentication (Session or JWT Token)
#[RateLimit(limit, time)]App\Attributes\RateLimitThrottles requests (e.g., 60 req/min)

Example: Protected API Controller

<?php
namespace App\Modules\Api\User\Controllers;

use App\Attributes\ApiAuth;
use App\Attributes\RateLimit;

#[ApiAuth]              // Requires valid session OR Bearer token
#[RateLimit(60, 60)]    // Max 60 requests per minute
class UserController extends Controller
{
    public function index() { /* List users */ }
    
    #[RateLimit(5, 60)]  // Stricter limit for delete
    public function delete($id) { /* Delete user */ }
}

JWT Authentication Flow

  1. Login:POST /api/auth/login with email/password → Returns JWT token.
  2. Use Token: Include Authorization: Bearer <token> in subsequent requests.
  3. Validation: The SecurityAttributeFilter validates the token via App\Infrastructure\Auth\Jwt\JwtService.
Configuration Required

Set JWT_SECRET in your .env file for production security:

JWT_SECRET=your_super_secret_long_random_key

Infrastructure Services

For full details, see the API & Security Documentation.


3. Module Structure (Detailed)

Full Example: Admin Module

Modules/Admin/ │ ├── Dashboard/ # FEATURE: Dashboard │ ├── Controllers/ │ │ └── DashboardController.php # Handles /admin/dashboard │ ├── Services/ │ │ └── DashboardService.php # Dashboard-specific logic │ └── Views/ │ ├── index.php # Main dashboard view │ └── analytics.php # Analytics sub-page │ ├── User/ # FEATURE: User Management │ ├── Controllers/ │ │ └── UserController.php # Handles /admin/users/* │ └── Views/ │ ├── index.php # Users list │ ├── show.php # Single user detail │ └── edit.php # Edit user form │ ├── Report/ # FEATURE: Reports │ ├── Controllers/ │ │ └── ReportController.php │ └── Views/ │ └── ... │ └── Shared/ # SHARED ACROSS ADMIN MODULE ├── Layouts/ │ └── admin.php # Admin layout (sidebar, header) └── Components/ ├── sidebar.php # Navigation sidebar ├── header.php # Page header ├── stat_card.php # Stats display card └── user_menu.php # User dropdown

Naming Conventions

ItemConventionExample
Feature folderPascalCase, singularDashboard/, User/
ControllerFeature + ControllerDashboardController.php
ServiceFeature + ServiceDashboardService.php
Viewslowercase, action nameindex.php, show.php
Componentssnake_casestat_card.php

CLI Generator

Use the make:module command to generate new modules quickly:

# Basic usage
php spark make:module <Group> <Name>

# Examples
php spark make:module Web Blog        # Creates app/Modules/Web/Blog/
php spark make:module Admin Product   # Creates app/Modules/Admin/Product/
php spark make:module Api Order       # Creates app/Modules/Api/Order/

# Overwrite existing
php spark make:module Web Blog --force

This command automatically creates:


4. Step-by-Step: Create a New Module

Scenario: Create a "Blog" Module in Web

Step 1: Create Directory Structure

# Create the folders
mkdir -p app/Modules/Web/Blog/Controllers
mkdir -p app/Modules/Web/Blog/Services
mkdir -p app/Modules/Web/Blog/Views

Result:

Modules/Web/ ├── Home/ ├── Docs/ └── Blog/ ← NEW ├── Controllers/ ├── Services/ └── Views/

Step 2: Create the Controller

File: app/Modules/Web/Blog/Controllers/BlogController.php

<?php

namespace App\Modules\Web\Blog\Controllers;

use App\Controllers\BaseController;
use App\Attributes\Route;
use App\Attributes\Trace;

class BlogController extends BaseController
{
    /**
     * List all blog posts
     */
    #[Route('/blog', methods: ['GET'])]
    #[Trace(name: 'blog.list')]
    public function index()
    {
        $posts = $this->getBlogService()->getRecentPosts(10);
        
        return view('Modules/Web/Blog/Views/index', [
            'title' => 'Blog',
            'posts' => $posts,
        ]);
    }

    /**
     * Show a single blog post
     */
    #[Route('/blog/{slug}', methods: ['GET'])]
    #[Trace(name: 'blog.show')]
    public function show(string $slug)
    {
        $post = $this->getBlogService()->findBySlug($slug);
        
        if ($post === null) {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }
        
        return view('Modules/Web/Blog/Views/show', [
            'title' => $post['title'],
            'post' => $post,
        ]);
    }

    private function getBlogService(): \App\Modules\Web\Blog\Services\BlogService
    {
        return new \App\Modules\Web\Blog\Services\BlogService();
    }
}

Step 3: Create the Service

File: app/Modules/Web/Blog/Services/BlogService.php

<?php

namespace App\Modules\Web\Blog\Services;

class BlogService
{
    public function getRecentPosts(int $limit = 10): array
    {
        // For now, return dummy data
        // Later, replace with repository calls
        return [
            ['slug' => 'hello-world', 'title' => 'Hello World', 'excerpt' => 'First post...'],
            ['slug' => 'getting-started', 'title' => 'Getting Started', 'excerpt' => 'Learn how...'],
        ];
    }

    public function findBySlug(string $slug): ?array
    {
        $posts = $this->getRecentPosts();
        
        foreach ($posts as $post) {
            if ($post['slug'] === $slug) {
                return $post;
            }
        }
        
        return null;
    }
}

Step 4: Create the Views

File: app/Modules/Web/Blog/Views/index.php

<?= $this->extend('Modules/Web/Shared/Layouts/public') ?>

<?= $this->section('content') ?>

<h1>Blog</h1>

<div class="blog-grid">
    <?php foreach ($posts as $post): ?>
    <article class="blog-card">
        <h2><a href="/blog/<?= esc($post['slug']) ?>">
            <?= esc($post['title']) ?>
        </a></h2>
        <p><?= esc($post['excerpt']) ?></p>
    </article>
    <?php endforeach; ?>
</div>

<?= $this->endSection() ?>

Step 5: Verify Routes

# Clear cache and check routes
docker-compose exec php php spark routes | grep blog

# Expected output:
# GET    /blog          BlogController::index
# GET    /blog/{slug}   BlogController::show

Step 6: Test in Browser

# Test the list page
curl http://localhost:81/blog

# Test a single post
curl http://localhost:81/blog/hello-world

5. Module Services vs Domain Services

Key Difference

TypeLocationPurposeFramework Aware?
Module ServiceModules/Admin/Dashboard/Services/UI-specific logic, data formattingYes, can use CI4
Domain ServiceDomain/User/Services/Core business logicNo, pure PHP

When to Use Each

// MODULE SERVICE: Dashboard stats with formatting
// File: Modules/Admin/Dashboard/Services/DashboardService.php
class DashboardService
{
    public function getStats(): array
    {
        // Get data and FORMAT for UI
        return [
            'users' => [
                'total' => number_format($this->userRepo->count()),
                'label' => 'Total Users',
                'color' => 'blue',
            ],
            'revenue' => [
                'total' => '$' . number_format($this->orderRepo->totalRevenue(), 2),
                'label' => 'Revenue',
                'color' => 'green',
            ],
        ];
    }
}

// DOMAIN SERVICE: Business logic for user registration
// File: Domain/User/Services/UserService.php
class UserService
{
    public function register(string $email, string $password): User
    {
        // Pure business logic - no formatting, no HTTP, no UI concerns
        $emailVO = new Email($email);
        
        if ($this->userRepo->emailExists($emailVO)) {
            throw new UserAlreadyExistsException($email);
        }
        
        $user = new User($emailVO, password_hash($password, PASSWORD_DEFAULT));
        $this->userRepo->save($user);
        
        return $user;
    }
}

6. Best Practices

Best Practices

  1. Keep modules focused — One module = one feature area
  2. Use Shared/ for reusable UI — Layouts, components used across the module
  3. Put common business logic in Domain — If multiple modules need it
  4. Keep Controllers thin — Delegate to Services

Anti-Patterns

  1. Don't import from other modules — Use Domain layer instead
  2. Don't put database queries in controllers — Use Services/Repositories
  3. Don't duplicate layouts — Put in Shared/Layouts

Example: Sharing Logic Between Modules

// WRONG: Admin module importing from Member module
namespace App\Modules\Admin\User\Controllers;
use App\Modules\Member\Account\Services\AccountService;  // BAD!

// CORRECT: Both modules use Domain layer
namespace App\Modules\Admin\User\Controllers;
use App\Domain\User\Services\UserService;  // GOOD!

namespace App\Modules\Member\Account\Controllers;
use App\Domain\User\Services\UserService;  // GOOD! Same shared service

7. Docker Verification

List All Modules

docker-compose exec php ls -la /var/www/html/app/Modules/

# Output:
# Admin
# Api
# Member
# Web

List Features in a Module

docker-compose exec php ls -la /var/www/html/app/Modules/Admin/

# Output:
# Dashboard
# User
# Shared

Check Routes for a Module

# All admin routes
docker-compose exec php php spark routes | grep admin

# All api routes
docker-compose exec php php spark routes | grep api

Clear Route Cache After Adding Module

# Delete route cache
docker-compose exec php rm -f /var/www/html/writable/cache/attribute_routes.php

# Or trigger rebuild by changing any controller file

8. Module Features Example: Analytics with D3 Charts

This section demonstrates how a module (Dashboard) integrates with shared infrastructure (D3 Chart Component) to deliver complex features (Real-time Analytics).

1. The Controller (DashboardController.php)

The controller handles both the view rendering and the JSON API endpoint for the charts.

#[Route('/analytics', methods: ['GET'])]
public function analytics() {
    return view('Modules/Admin/Dashboard/Views/analytics', ['title' => 'Analytics']);
}

#[Route('/api/analytics/realtime', methods: ['GET'])]
public function analyticsData() {
    // Returns JSON data for AJAX charts
    return $this->response->setJSON([...]);
}

2. The View (analytics.php)

The view utilizes the shared d3_chart component, keeping the module code clean and focused.

<!-- Sales Trend Chart -->
<?= component('d3_chart', [
    'id' => 'salesTrend',
    'type' => 'area',
    'dataUrl' => '/admin/api/analytics/realtime', // Fetches from Controller API
    'colors' => ['#3b82f6'],
]) ?>

<!-- Market Share Chart -->
<?= component('d3_chart', [
    'id' => 'marketShare',
    'type' => 'treemap', // Specialized D3 visualization
    'data' => [...],     // Static data
]) ?>

2.1 Chart Interactions

Both ApexCharts and D3 charts support click interactions:

<?= component('chart', [
    'id' => 'salesChart',
    // Click to redirect
    'clickUrl' => '/admin/sales/detail?date={label}&value={value}',
    
    // Or click to open modal with AJAX content
    'clickModal' => '/admin/api/analytics/detail?label={label}&value={value}',
]) ?>

Placeholders: {label}, {value}, {series}, {index}

2.2 Chart Filter Binding

Charts can auto-refresh when filter elements change:

<!-- Filter controls -->
<select id="categoryFilter">...</select>
<select id="periodFilter">...</select>

<!-- Chart with filters -->
<?= component('d3_chart', [
    'id' => 'salesTrend',
    'dataUrl' => '/admin/api/analytics/realtime',
    'filters' => [
        'category' => '#categoryFilter',
        'period' => '#periodFilter',
    ],
]) ?>

<!-- Or bind to a form -->
<?= component('chart', [
    'id' => 'revenueChart',
    'dataUrl' => '/api/revenue/chart',
    'filterForm' => '#chartFiltersForm',
]) ?>

When a filter changes, the chart automatically fetches new data with updated query parameters.

3. The Result

This architecture keeps concerns separated:


9. Unit Testing

The project includes comprehensive unit tests using PHPUnit. Tests are located in tests/ directory.

Running Tests

# Run all tests
docker-compose exec php php spark test

# Run specific test file
docker-compose exec php php spark test --filter SecurityTest

# Run with coverage report
docker-compose exec php php spark test --coverage-html writable/coverage

Test Suite Overview

The project includes a comprehensive test suite with 80+ test methods across all modules:

Test FileModuleTestsCoverage
SecurityTest.phpCore12JWT, CSRF, Headers, Rate Limiting
JwtServiceTest.phpInfrastructure15Token encode/decode, tampering, edge cases
AdminModuleTest.phpAdmin12Dashboard, User CRUD, Auth, AJAX endpoints
ApiModuleTest.phpApi14Ping, Health, User API, JWT Auth
WebModuleTest.phpWeb16Home, Docs pages, Search, 404, Security
RbacTest.phpRBAC14Roles, Permissions, Menus, Filters
LoginFlowTest.phpFeature15Full auth flows, Session vs Token

Running Tests

# Run all tests
docker-compose exec php php spark test

# Run specific module tests
docker-compose exec php php spark test --filter AdminModuleTest
docker-compose exec php php spark test --filter ApiModuleTest
docker-compose exec php php spark test --filter WebModuleTest
docker-compose exec php php spark test --filter RbacTest

# Run feature tests
docker-compose exec php php spark test --filter LoginFlowTest

# Run with coverage report
docker-compose exec php php spark test --coverage-html writable/coverage

Test Directory Structure

tests/
├── unit/
│   ├── SecurityTest.php       # Core security (JWT, CSRF, Headers)
│   ├── JwtServiceTest.php     # JWT encode/decode/tampering
│   ├── AdminModuleTest.php    # Dashboard, User, Auth controllers
│   ├── ApiModuleTest.php      # REST API endpoints
│   ├── WebModuleTest.php      # Public pages, Docs
│   ├── RbacTest.php           # Roles, Permissions, Menus
│   └── HealthTest.php         # Basic health checks
├── feature/
│   └── LoginFlowTest.php      # End-to-end auth flows
├── database/
│   └── MigrationTest.php      # Database migrations
└── _support/
    └── ... (test helpers)

Example: Creating a Module Test

<?php
namespace Tests\Unit;

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;

class MyModuleTest extends CIUnitTestCase
{
    use FeatureTestTrait;

    public function testPageLoadsWithAuth(): void
    {
        $result = $this->withSession([
            'user_id' => 1,
            'is_logged_in' => true
        ])->call('get', '/admin/my-page');
        
        $result->assertStatus(200);
    }

    public function testApiWithJwtToken(): void
    {
        $jwt = new \App\Infrastructure\Auth\Jwt\JwtService();
        $token = $jwt->encode(['sub' => 1]);
        
        $result = $this->withHeaders([
            'Authorization' => 'Bearer ' . $token
        ])->call('get', '/api/my-endpoint');
        
        $this->assertNotEquals(401, $result->response()->getStatusCode());
    }
}

Manual Testing with cURL

# Test API without token (should fail)
curl -X GET http://localhost:81/api/users
# Expected: 401 Unauthorized

# Get JWT token
curl -X POST http://localhost:81/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "password"}'

# Test with token
curl -X GET http://localhost:81/api/users \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

# Test security headers
curl -I http://localhost:81/
# Look for: X-Frame-Options, X-Content-Type-Options
ESC

Start typing to search the documentation