Files
alrahma_sunday_school/school_year_code_update_plan_codeigniter4.md
T
root e06ccc9cc0
Tests / PHPUnit (push) Failing after 40s
ADD SCHOOL YEAR MANAGEMENT
2026-07-12 02:21:39 -04:00

46 KiB

School-Year-Aware Code Update Plan for CodeIgniter 4

Goal

Update the CodeIgniter 4 application so school-year filtering is applied only to records that are logically owned by a school year.

The application must stop assuming that every table containing a school_year column should be filtered by it. Some tables are year-owned, some are global, some contain global identities with year-specific relationship records, and some contain optional school-year context.

Required behavior:

  • Active-year pages display only active-year records.
  • A selected historical year can be viewed intentionally.
  • Closed or archived years are read-only unless an explicit administrative operation allows changes.
  • Authentication, authorization, configuration, identity, and infrastructure tables remain global.
  • Finance, attendance, exams, events, progress, and reports never mix records from different years.
  • Missing required school-year context causes a clear error instead of returning unfiltered records.
  • The backend validates all school-year parameters. Query parameters are input, not truth.

Current Problem

A broad database script added or populated school_year = 2025-2026 across many tables. Some of those tables should never be school-year scoped.

Examples of tables that must not be filtered directly by school year:

  • users
  • roles
  • permissions
  • role_permissions
  • user_roles
  • settings
  • configuration
  • preferences
  • school_years
  • login_activity
  • ip_attempts
  • password_resets
  • migrations
  • families
  • students
  • parents
  • teachers
  • staff

CodeIgniter models and services must not decide scope based only on whether a school_year column exists. A bad migration can add a column. It cannot redefine the business meaning of a table, despite databases occasionally behaving as if they have opinions.


Current Database Assumptions

Treat the following as the working database state before application changes:

  • Valid year-owned records are tagged with school_year = 2025-2026.
  • Optional-context tables keep school_year nullable and without a default.
  • notifications, user_notifications, and payment_notification_logs were verified with no forced school-year value after cleanup.
  • Some global tables may still physically contain an incorrect school_year column until application references are removed and a cleanup migration is executed.

Immediate application rule:

YEAR_SCOPED:
    Require validated selected school-year context.

GLOBAL:
    Never add a school_year condition.

IDENTITY_WITH_YEAR_RELATION:
    Load the identity globally and filter participation through an enrollment,
    assignment, membership, or other year-owned relationship table.

CONTEXT:
    Keep school_year nullable and filter it only in explicitly year-specific views.

Do not run another script that stamps all nullable context rows with the active year.


Target Table Classification

Create one explicit source of truth for table behavior.

1. Year-Owned Tables

These tables require school-year filtering for normal reads and writes.

Examples:

  • classes
  • classSection
  • sections
  • enrollments
  • student_class
  • teacher_class
  • attendance_data
  • attendance_day
  • attendance_record
  • attendance_tracking
  • class_progress_reports
  • exam_drafts
  • exams
  • final_exam
  • final_score
  • semester_scores
  • invoices
  • invoice_installments
  • payments
  • payment_transactions
  • manual_payments
  • expenses
  • refunds
  • calendar_events
  • events
  • parent_notifications
  • parent_meeting_schedules
  • print_requests
  • report_card_acknowledgements
  • whatsapp_group_links
  • whatsapp_group_memberships

CodeIgniter query-builder rule:

$builder->where('school_year', $context->yearName());

Long-term preferred rule:

$builder->where('school_year_id', $context->id());

2. Global Tables

These tables must never receive automatic school-year filtering.

Examples:

  • users
  • authorized_users
  • roles
  • permissions
  • role_permissions
  • user_roles
  • nav_items
  • role_nav_items
  • parent_accounts
  • settings
  • configuration
  • preferences
  • user_preferences
  • email_templates
  • school_years
  • migrations
  • cache
  • cache_locks
  • sessions
  • personal_access_tokens
  • password_resets
  • password_reset_requests

Rule:

// No school_year condition.

3. Global Identity Tables With Year-Owned Relationships

Identity records are global. Their participation in a school year is year-specific.

Global identity examples:

  • families
  • parents
  • students
  • teachers
  • staff
  • family_guardians
  • family_students
  • emergency_contacts
  • student_allergies
  • student_medical_conditions

Year-specific relationship examples:

enrollments
- student_id
- school_year_id or school_year
- class_id
- status
family_school_years
- family_id
- school_year_id or school_year
- status
- opening_balance
teacher_class
- teacher_id
- class_id
- school_year_id or school_year

Correct CodeIgniter query pattern:

$builder = $db->table('students s');
$builder
    ->join('enrollments e', 'e.student_id = s.id')
    ->where('e.school_year', $context->yearName())
    ->select('s.*')
    ->distinct();

Incorrect pattern:

$db->table('students')
    ->where('students.school_year', $context->yearName());

Use the real relationship path defined by the schema. Do not invent a direct join because it looks pleasantly short.

4. Nullable Context Tables

These tables are global or operational records that may optionally include school-year context.

Examples:

  • audit_logs
  • communication_logs
  • notifications
  • notification_recipients
  • user_notifications
  • messages
  • support_requests
  • contactus
  • finance_notification_logs
  • payment_notification_logs

Rules:

  • school_year remains nullable.
  • Normal global views must not automatically hide rows.
  • Year-specific views may include global rows plus selected-year rows.
  • Strict school-year reports may include only selected-year rows.
  • Creation logic must not automatically stamp the active year unless the action is genuinely year-specific.

Optional-context query:

$builder
    ->groupStart()
        ->where('school_year', null)
        ->orWhere('school_year', $context->yearName())
    ->groupEnd();

Strict year-only query:

$builder->where('school_year', $context->yearName());

Phase 1: Add Central Table Metadata

Create:

app/Support/SchoolYear/SchoolYearTableRegistry.php
<?php

namespace App\Support\SchoolYear;

final class SchoolYearTableRegistry
{
    public const YEAR_SCOPED = [
        'classes',
        'classSection',
        'sections',
        'enrollments',
        'student_class',
        'teacher_class',
        'attendance_data',
        'attendance_day',
        'attendance_record',
        'attendance_tracking',
        'class_progress_reports',
        'exam_drafts',
        'exams',
        'final_exam',
        'final_score',
        'semester_scores',
        'invoices',
        'invoice_installments',
        'payments',
        'payment_transactions',
        'manual_payments',
        'expenses',
        'refunds',
        'calendar_events',
        'events',
        'parent_notifications',
        'parent_meeting_schedules',
        'print_requests',
        'report_card_acknowledgements',
        'whatsapp_group_links',
        'whatsapp_group_memberships',
    ];

    public const GLOBAL = [
        'users',
        'authorized_users',
        'roles',
        'permissions',
        'role_permissions',
        'user_roles',
        'nav_items',
        'role_nav_items',
        'parent_accounts',
        'settings',
        'configuration',
        'preferences',
        'user_preferences',
        'email_templates',
        'school_years',
        'migrations',
        'cache',
        'cache_locks',
        'sessions',
        'personal_access_tokens',
        'password_resets',
        'password_reset_requests',
    ];

    public const IDENTITY_WITH_YEAR_RELATION = [
        'families',
        'parents',
        'students',
        'teachers',
        'staff',
        'family_guardians',
        'family_students',
        'emergency_contacts',
        'student_allergies',
        'student_medical_conditions',
    ];

    public const CONTEXT = [
        'audit_logs',
        'communication_logs',
        'notifications',
        'notification_recipients',
        'user_notifications',
        'messages',
        'support_requests',
        'contactus',
        'finance_notification_logs',
        'payment_notification_logs',
    ];

    public static function isYearScoped(string $table): bool
    {
        return in_array($table, self::YEAR_SCOPED, true);
    }

    public static function isGlobal(string $table): bool
    {
        return in_array($table, self::GLOBAL, true);
    }

    public static function isIdentityWithYearRelation(string $table): bool
    {
        return in_array($table, self::IDENTITY_WITH_YEAR_RELATION, true);
    }

    public static function isContext(string $table): bool
    {
        return in_array($table, self::CONTEXT, true);
    }

    public static function categoryOf(string $table): string
    {
        return match (true) {
            self::isYearScoped($table) => 'YEAR_SCOPED',
            self::isGlobal($table) => 'GLOBAL',
            self::isIdentityWithYearRelation($table) => 'IDENTITY_WITH_YEAR_RELATION',
            self::isContext($table) => 'CONTEXT',
            default => throw new \InvalidArgumentException(
                "Table '{$table}' is not registered for school-year behavior."
            ),
        };
    }
}

Every table touched by school-year logic must be classified. An unclassified table should fail review or automated checks rather than quietly inheriting whatever condition happened to be nearby.


Phase 2: Add a School-Year Context Value Object

Create:

app/Support/SchoolYear/SchoolYearContext.php
<?php

namespace App\Support\SchoolYear;

final class SchoolYearContext
{
    public function __construct(
        private readonly int $id,
        private readonly string $yearName,
        private readonly string $status,
        private readonly bool $explicitSelection = false,
    ) {
    }

    public function id(): int
    {
        return $this->id;
    }

    public function yearName(): string
    {
        return $this->yearName;
    }

    public function status(): string
    {
        return $this->status;
    }

    public function isActive(): bool
    {
        return $this->status === 'active';
    }

    public function isReadonly(): bool
    {
        return in_array($this->status, ['closed', 'archived'], true);
    }

    public function isExplicitSelection(): bool
    {
        return $this->explicitSelection;
    }
}

Do not pass raw strings such as 2025-2026 across controllers and services as the main context object. Raw strings cannot carry status, ID, writability, or validation state.


Phase 3: Add a SchoolYearContextService

Create or update:

app/Services/SchoolYearContextService.php

Responsibilities:

  1. Read explicit school year from route placeholders or query parameters.
  2. Support school_year_id and school_year during the transition period.
  3. Validate that the requested school year exists.
  4. Validate that the authenticated user may access it.
  5. Fall back to a session-selected year.
  6. Fall back to the active year.
  7. Fail clearly if no valid year can be resolved.
  8. Return a SchoolYearContext object.
  9. Never trust the frontend to determine whether a year is writable.

Resolution order:

1. Route placeholder
2. Query parameter school_year_id
3. Query parameter school_year
4. Session-selected school year
5. Active school year from school_years
6. Throw a controlled exception if none exists

Suggested implementation outline:

<?php

namespace App\Services;

use App\Models\SchoolYearModel;
use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\HTTP\IncomingRequest;
use RuntimeException;

final class SchoolYearContextService
{
    public function __construct(
        private readonly SchoolYearModel $schoolYearModel,
    ) {
    }

    public function resolve(
        IncomingRequest $request,
        ?int $routeSchoolYearId = null
    ): SchoolYearContext {
        $requestedId = $routeSchoolYearId
            ?? $this->normalizeInt($request->getGet('school_year_id'));

        $requestedName = trim((string) $request->getGet('school_year'));

        if ($requestedId !== null) {
            $row = $this->schoolYearModel->find($requestedId);

            if ($row === null) {
                throw new RuntimeException('Selected school year was not found.');
            }

            return $this->fromRow($row, true);
        }

        if ($requestedName !== '') {
            $row = $this->schoolYearModel
                ->where('name', $requestedName)
                ->first();

            if ($row === null) {
                throw new RuntimeException('Selected school year was not found.');
            }

            return $this->fromRow($row, true);
        }

        $sessionYearId = session('selected_school_year_id');

        if (is_numeric($sessionYearId)) {
            $row = $this->schoolYearModel->find((int) $sessionYearId);

            if ($row !== null) {
                return $this->fromRow($row, false);
            }
        }

        $active = $this->schoolYearModel
            ->where('status', 'active')
            ->orderBy('id', 'DESC')
            ->first();

        if ($active === null) {
            throw new RuntimeException('No active school year is configured.');
        }

        return $this->fromRow($active, false);
    }

    private function normalizeInt(mixed $value): ?int
    {
        if ($value === null || $value === '' || ! ctype_digit((string) $value)) {
            return null;
        }

        return (int) $value;
    }

    private function fromRow(array $row, bool $explicit): SchoolYearContext
    {
        return new SchoolYearContext(
            id: (int) $row['id'],
            yearName: (string) $row['name'],
            status: (string) $row['status'],
            explicitSelection: $explicit,
        );
    }
}

Register the service in:

app/Config/Services.php
public static function schoolYearContext(bool $getShared = true)
{
    if ($getShared) {
        return static::getSharedInstance('schoolYearContext');
    }

    return new \App\Services\SchoolYearContextService(
        model(\App\Models\SchoolYearModel::class)
    );
}

Use dependency injection where practical. Calling service() from every random method is convenient until testing begins and the convenience invoice arrives.


Phase 4: Add a Write Guard

Create:

app/Services/SchoolYearWriteGuard.php
<?php

namespace App\Services;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\Exceptions\PageForbiddenException;

final class SchoolYearWriteGuard
{
    public function assertWritable(
        SchoolYearContext $context,
        bool $allowDraftForAdmin = false,
        bool $isAdmin = false
    ): void {
        if ($context->status() === 'active') {
            return;
        }

        if (
            $context->status() === 'draft'
            && $allowDraftForAdmin
            && $isAdmin
        ) {
            return;
        }

        throw PageForbiddenException::forPageForbidden(
            'The selected school year is read-only.'
        );
    }
}

Suggested status behavior:

active:
    Writable.

closing:
    Restricted to explicit closing operations.

closed:
    Read-only.

archived:
    Read-only.

draft:
    Admin-only when the operation explicitly permits draft changes.

Apply the guard to all writes affecting year-owned tables, including:

  • invoices
  • payments
  • attendance
  • exams
  • scores
  • classes and sections
  • student placement
  • teacher assignment
  • events
  • report-card acknowledgements
  • WhatsApp group assignments
  • carry-forward operations

Do not apply it to global user, role, permission, or configuration updates unless those operations modify year-owned data.


Phase 5: Add Reusable Model Methods

CodeIgniter 4 does not provide Laravel-style Eloquent scopes or relationships. Do not copy whereHas(), traits returning Eloquent builders, or Model::query() into CI4 and hope PHP develops telepathy.

Use explicit model methods or repository/service query methods.

Base method for year-owned models

Create:

app/Models/Concerns/SchoolYearScopedModelTrait.php
<?php

namespace App\Models\Concerns;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\Model;

trait SchoolYearScopedModelTrait
{
    public function forSchoolYear(
        SchoolYearContext|string|int $schoolYear
    ): Model {
        if ($schoolYear instanceof SchoolYearContext) {
            if ($this->fieldExists('school_year_id')) {
                return $this->where(
                    $this->table . '.school_year_id',
                    $schoolYear->id()
                );
            }

            return $this->where(
                $this->table . '.school_year',
                $schoolYear->yearName()
            );
        }

        if (is_int($schoolYear)) {
            return $this->where(
                $this->table . '.school_year_id',
                $schoolYear
            );
        }

        return $this->where(
            $this->table . '.school_year',
            $schoolYear
        );
    }

    private function fieldExists(string $field): bool
    {
        return in_array($field, $this->db->getFieldNames($this->table), true);
    }
}

Important correction: the registry, not fieldExists(), decides whether a table is year-scoped. fieldExists() may only choose between the transitional columns school_year_id and school_year after the model has already been classified as year-owned.

Prefer a simpler explicit version per model during the transition:

public function forSchoolYear(SchoolYearContext $context): self
{
    return $this->where('school_year', $context->yearName());
}

This is less magical and easier to audit.

Optional-context model methods

Create methods only on applicable models:

public function includeGlobalAndYear(
    SchoolYearContext $context
): self {
    return $this
        ->groupStart()
            ->where('school_year', null)
            ->orWhere('school_year', $context->yearName())
        ->groupEnd();
}

public function onlyForYear(
    SchoolYearContext $context
): self {
    return $this->where('school_year', $context->yearName());
}

Do Not Add School-Year Methods To Global Models

Do not add automatic school-year behavior to:

  • UserModel
  • RoleModel
  • PermissionModel
  • SettingModel
  • ConfigurationModel
  • SchoolYearModel
  • StudentModel
  • FamilyModel
  • ParentModel
  • TeacherModel
  • StaffModel

Identity models may expose methods that join year-owned relationship tables, but the identity table itself remains global.


Phase 6: Add Repositories or Domain Services for Complex Queries

Use dedicated services or repositories for cross-table queries instead of placing joins in controllers.

Suggested files:

app/Repositories/StudentRepository.php
app/Repositories/FamilyRepository.php
app/Repositories/TeacherRepository.php
app/Repositories/FinanceRepository.php
app/Services/Finance/BalanceService.php
app/Services/SchoolYearClosingService.php

Example student roster query:

<?php

namespace App\Repositories;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\Database\BaseConnection;

final class StudentRepository
{
    public function __construct(
        private readonly BaseConnection $db,
    ) {
    }

    public function findRosterForYear(
        SchoolYearContext $context,
        ?int $classId = null
    ): array {
        $builder = $this->db->table('students s');

        $builder
            ->select('s.*, e.class_id, e.status AS enrollment_status')
            ->join('enrollments e', 'e.student_id = s.id')
            ->where('e.school_year', $context->yearName());

        if ($classId !== null) {
            $builder->where('e.class_id', $classId);
        }

        return $builder
            ->orderBy('s.last_name', 'ASC')
            ->orderBy('s.first_name', 'ASC')
            ->get()
            ->getResultArray();
    }
}

Example global profile with year-owned child data:

$family = $familyModel->find($familyId);

if ($family === null) {
    throw PageNotFoundException::forPageNotFound('Family not found.');
}

$invoices = $invoiceModel
    ->where('family_id', $familyId)
    ->forSchoolYear($context)
    ->findAll();

Phase 7: Resolve Context in Controllers Consistently

Create a base API controller helper or a small controller trait.

Suggested file:

app/Controllers/BaseApiController.php
<?php

namespace App\Controllers;

use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\RESTful\ResourceController;
use Throwable;

abstract class BaseApiController extends ResourceController
{
    protected function resolveSchoolYearContext(
        ?int $routeSchoolYearId = null
    ): SchoolYearContext {
        try {
            return service('schoolYearContext')->resolve(
                $this->request,
                $routeSchoolYearId
            );
        } catch (Throwable $e) {
            log_message('warning', 'School-year resolution failed: {message}', [
                'message' => $e->getMessage(),
            ]);

            throw $e;
        }
    }
}

Controller usage:

public function index()
{
    $context = $this->resolveSchoolYearContext();

    $rows = $this->invoiceModel
        ->forSchoolYear($context)
        ->orderBy('id', 'DESC')
        ->findAll();

    return $this->respond([
        'school_year' => [
            'id' => $context->id(),
            'name' => $context->yearName(),
            'status' => $context->status(),
            'readonly' => $context->isReadonly(),
        ],
        'data' => $rows,
    ]);
}

For year-owned endpoints, never silently continue without a valid context.


Phase 8: Update Routes

Use explicit school-year routes for year-owned resources.

In:

app/Config/Routes.php

Recommended structure:

$routes->group('api/v1', ['filter' => 'auth'], static function ($routes) {
    // Global resources
    $routes->get('users', 'Api\UserController::index');
    $routes->get('roles', 'Api\RoleController::index');
    $routes->get('permissions', 'Api\PermissionController::index');
    $routes->get('settings', 'Api\SettingController::index');
    $routes->get('school-years', 'Api\SchoolYearController::index');

    // Current/resolved school year
    $routes->group('school-years/current', static function ($routes) {
        $routes->get('classes', 'Api\ClassController::index');
        $routes->get('families', 'Api\FamilyYearController::index');
        $routes->get(
            'reports/closing',
            'Api\SchoolYearClosingController::show'
        );
    });

    // Explicit selected school year
    $routes->group(
        'school-years/(:num)',
        static function ($routes) {
            $routes->get('classes', 'Api\ClassController::index/$1');
            $routes->get(
                'families',
                'Api\FamilyYearController::index/$1'
            );
            $routes->get(
                'reports/closing',
                'Api\SchoolYearClosingController::show/$1'
            );
        }
    );

    // Global profile with year-specific child endpoints
    $routes->get('families/(:num)', 'Api\FamilyController::show/$1');
    $routes->get(
        'school-years/(:num)/families/(:num)/invoices',
        'Api\FamilyInvoiceController::index/$1/$2'
    );
});

Do not put global resources under school-year routes merely because the frontend currently appends school_year to every URL.

Bad:

/api/v1/school-years/4/users

Correct:

/api/v1/users
/api/v1/school-years/4/teacher-assignments

Maintain compatibility with current query parameters temporarily, but route all resolution through SchoolYearContextService.


Phase 9: Add Request Filters Only for Cross-Cutting Validation

A CodeIgniter filter may validate that a route requiring school-year context has one, but it must not mutate every database query globally.

Suggested filter:

app/Filters/RequireSchoolYearFilter.php

Responsibilities:

  • Resolve and validate school-year context.
  • Store the resolved context as a request attribute substitute, service state, or controller-accessible object.
  • Return a controlled 400, 403, or 404 response when invalid.
  • Never add SQL conditions itself.
  • Never run on global endpoints.

Register an alias in:

app/Config/Filters.php
public array $aliases = [
    // ...
    'schoolYear' => \App\Filters\RequireSchoolYearFilter::class,
];

Apply it only to year-owned route groups.

Do not implement a universal model callback that appends school_year to every query. That would recreate the original bug with more ceremony.


Phase 10: Audit and Replace Existing Queries

Search the CodeIgniter project:

rg -n "school_year" app tests database
rg -n "school_year_id" app tests database
rg -n "getGet\(['\"]school_year" app
rg -n "getVar\(['\"]school_year" app
rg -n "where\(['\"][^'\"]*school_year" app
rg -n "db->table|builder\(" app
rg -n "allowedFields" app/Models

Classify every use:

  1. Year-owned table
    Keep the filter, but obtain the value from validated context.

  2. Global table
    Remove the filter and remove school_year from model allowedFields.

  3. Identity table
    Replace direct filtering with joins through enrollment or assignment tables.

  4. Optional-context table
    Choose global, global-plus-year, or strict-year behavior based on the endpoint.

  5. Raw SQL
    Add explicit table aliases and scope only the year-owned table.

  6. Write path
    Add the write guard and force the year value from backend context.

Bad:

$currentYear = $this->request->getGet('school_year');

$users = $this->userModel
    ->where('school_year', $currentYear)
    ->findAll();

Correct:

$users = $this->userModel
    ->orderBy('last_name', 'ASC')
    ->orderBy('first_name', 'ASC')
    ->findAll();

Bad:

$students = $this->studentModel
    ->where('school_year', $currentYear)
    ->findAll();

Correct:

$context = $this->resolveSchoolYearContext();

$students = $this->studentRepository
    ->findRosterForYear($context);

Bad write:

$data = $this->request->getJSON(true);
$this->paymentModel->insert($data);

Correct write:

$context = $this->resolveSchoolYearContext();
service('schoolYearWriteGuard')->assertWritable($context);

$data = $this->request->getJSON(true) ?? [];
$data['school_year'] = $context->yearName();
$data['school_year_id'] = $context->id();

unset($data['requested_school_year'], $data['is_readonly']);

$this->paymentModel->insert($data);

The client must not be allowed to write into one year while displaying another.


Phase 11: Update Model Definitions

Review every CodeIgniter model.

For year-owned models:

  • Include school_year and, during transition, school_year_id in $allowedFields.
  • Add validation rules where required.
  • Add forSchoolYear() or equivalent explicit methods.
  • Do not automatically default to the active year inside beforeInsert unless every creation path is guaranteed to be year-specific.
  • Prefer services/controllers to set the validated year explicitly.

Example:

<?php

namespace App\Models;

use App\Models\Concerns\SchoolYearScopedModelTrait;
use CodeIgniter\Model;

class InvoiceModel extends Model
{
    use SchoolYearScopedModelTrait;

    protected $table = 'invoices';
    protected $primaryKey = 'id';
    protected $returnType = 'array';

    protected $allowedFields = [
        'family_id',
        'student_id',
        'amount',
        'balance',
        'status',
        'school_year',
        'school_year_id',
        'created_by',
        'updated_by',
    ];

    protected $validationRules = [
        'family_id' => 'required|is_natural_no_zero',
        'amount' => 'required|decimal',
        'school_year' => 'required|max_length[20]',
    ];
}

For global models:

  • Remove school_year and school_year_id from $allowedFields.
  • Remove school-year validation rules.
  • Remove beforeFind, beforeInsert, or beforeUpdate callbacks that add the active year.
  • Remove constructor logic reading configuration.semester or configuration.school_year unless the model genuinely owns year-specific records.

For optional-context models:

  • Keep school_year nullable.
  • Do not mark it required.
  • Populate it only when the action has explicit school-year meaning.

Phase 12: Update Finance First

Finance is the highest-risk area.

Audit:

  • family and parent balances
  • invoice listings
  • invoice installment calculations
  • payment listings
  • payment allocation
  • manual payment creation
  • reimbursements
  • refunds
  • discounts and discount usage
  • event charges
  • carry-forward calculations
  • closing previews
  • closing reports
  • opening balances
  • transaction and notification logs

Rules:

  1. Current-year balances use only current-year financial records.
  2. Historical debt does not appear as current-year overdue debt unless an explicit carry-forward transaction exists.
  3. Carry-forward creates a target-year record. It must not mutate historical invoices.
  4. Closing previews use the selected year, not whatever year is globally active.
  5. Every source table in an aggregate query must be reviewed separately.
  6. Payment transactions and invoice-event links must inherit or explicitly store the same school year as their parent record.
  7. Optional logs may remain null and must not be treated as financial ownership records.

Example balance service:

public function calculateFamilyBalance(
    int $familyId,
    SchoolYearContext $context
): array {
    $invoiceTotal = (float) $this->db
        ->table('invoices')
        ->selectSum('total_amount', 'total')
        ->where('family_id', $familyId)
        ->where('school_year', $context->yearName())
        ->get()
        ->getRow('total');

    $paymentTotal = (float) $this->db
        ->table('payments')
        ->selectSum('paid_amount', 'total')
        ->where('family_id', $familyId)
        ->where('school_year', $context->yearName())
        ->get()
        ->getRow('total');

    return [
        'invoice_total' => $invoiceTotal,
        'payment_total' => $paymentTotal,
        'balance' => $invoiceTotal - $paymentTotal,
    ];
}

Do not compute a balance by summing all historical invoices and payments for a family. That is not an accounting shortcut. It is data contamination.

Use database transactions for closing and carry-forward operations:

$this->db->transException(true)->transStart();

try {
    // Lock/check source year.
    // Create target-year carry-forward records.
    // Mark closing batch.
    // Write audit records.
    $this->db->transComplete();
} catch (\Throwable $e) {
    log_message('error', 'School-year closing failed: {message}', [
        'message' => $e->getMessage(),
    ]);

    throw $e;
}

Phase 13: Update Academic Queries

Audit:

  • classes
  • sections
  • class-section mappings
  • enrollments
  • student placement
  • teacher assignments
  • attendance
  • progress reports
  • exam drafts
  • exams
  • semester scores
  • final scores
  • promotion
  • report cards

Rules:

  • students and teachers remain global.
  • classes, sections, assignments, and enrollments are year-owned.
  • Active-year rosters come from enrollment tables.
  • Teacher pages derive students from year-owned assignments and enrollments.
  • Reports always use the selected school year.
  • Semester filtering is applied only to semester-owned academic records, not global identity or annual records.

Example:

$builder = $this->db->table('classes c');

$classes = $builder
    ->select('c.*, COUNT(DISTINCT e.student_id) AS student_count')
    ->join(
        'enrollments e',
        'e.class_id = c.id AND e.school_year = c.school_year',
        'left'
    )
    ->where('c.school_year', $context->yearName())
    ->groupBy('c.id')
    ->orderBy('c.name', 'ASC')
    ->get()
    ->getResultArray();

When both tables are year-owned, join on identifiers and ensure their school-year values agree.


Phase 14: Update Identity and Profile Pages

Audit:

  • parent profiles
  • family management
  • student profiles
  • teacher profiles
  • staff pages

Rules:

  1. Profile identity loads by global primary key.
  2. Year-specific tabs use the selected context.
  3. Historical profile activity remains accessible.
  4. A person is not hidden globally merely because they lack an enrollment for the selected year.
  5. Roster pages may show only identities participating in the selected year.
  6. Profile pages and roster pages are different use cases. Do not force them through one misleading query.

Example:

$parent = $this->parentModel->find($parentId);

if ($parent === null) {
    return $this->failNotFound('Parent not found.');
}

$invoices = $this->invoiceModel
    ->where('parent_id', $parentId)
    ->forSchoolYear($context)
    ->findAll();

return $this->respond([
    'parent' => $parent,
    'school_year' => $context->yearName(),
    'invoices' => $invoices,
]);

Phase 15: Remove School-Year Logic From Global Features

Remove year filtering from:

  • login
  • token validation
  • password reset
  • sessions
  • user management
  • role and permission checks
  • navigation/menu construction
  • settings
  • configuration
  • global email templates
  • cache
  • migrations
  • system health endpoints

Bad:

$this->permissionModel
    ->where('school_year', $context->yearName())
    ->findAll();

Correct:

$this->permissionModel
    ->orderBy('name', 'ASC')
    ->findAll();

If a setting truly varies by year, create a separate table:

school_year_settings
- id
- school_year_id
- config_key
- config_value

Do not turn configuration into a table where some rows are global and some are secretly annual without an explicit design.


Phase 16: Update Frontend Integration

The backend remains authoritative, but the frontend should use a centralized school-year state.

Required frontend state:

activeSchoolYear
selectedSchoolYear
selectedSchoolYearId
selectedSchoolYearName
selectedSchoolYearStatus
isReadonly

Frontend rules:

  1. Year-owned pages send the selected school-year ID.
  2. Global pages do not depend on school-year parameters.
  3. Navigation preserves the selected year only where useful.
  4. Mutation buttons are disabled for read-only years.
  5. The API still enforces writability even when buttons are disabled.
  6. Dropdown lists use alphabetical ordering where applicable.
  7. Tables support sorting consistently.
  8. A selected year in the URL must match the resolved year returned by the API.

Recommended API usage:

api.get(`/api/v1/school-years/${selectedSchoolYearId}/classes`);
api.get(`/api/v1/school-years/${selectedSchoolYearId}/reports/closing`);

api.get('/api/v1/users');
api.get('/api/v1/roles');
api.get('/api/v1/settings');

api.get(`/api/v1/families/${familyId}`);
api.get(
  `/api/v1/school-years/${selectedSchoolYearId}/families/${familyId}/invoices`
);

During migration, existing endpoints using:

?school_year_id=4&school_year=2025-2026

may remain, but the backend must reject conflicting values rather than choosing whichever one happens to be read first.


Phase 17: Add CodeIgniter Tests

Use CodeIgniter 4 testing utilities and PHPUnit.

Suggested test locations:

tests/unit/Support/SchoolYear/
tests/unit/Services/
tests/database/Models/
tests/feature/Api/

Run:

php spark test

or:

vendor/bin/phpunit

Unit Tests

Test:

  • SchoolYearTableRegistry::categoryOf()
  • context resolution precedence
  • invalid ID handling
  • invalid year-name handling
  • active-year fallback
  • read-only status handling
  • write-guard behavior

Database Tests

Use:

use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;

Test year-owned models:

  • classes return only selected-year rows
  • attendance returns only selected-year rows
  • invoices return only selected-year rows
  • payments return only selected-year rows
  • progress reports return only selected-year rows

Test global models:

  • users load regardless of selected year
  • roles load regardless of selected year
  • permissions load regardless of selected year
  • configuration loads regardless of selected year
  • all school years remain visible

Test identity behavior:

  • student profile loads globally
  • student roster changes by selected year
  • family profile loads globally
  • family balance changes by selected year
  • teacher profile loads globally
  • teacher assignments change by selected year

Test context tables:

  • null-context logs remain visible in global views
  • selected-year views include the intended rows
  • strict reports exclude unrelated and null-context rows
  • notification rows are not automatically stamped

Feature/API Tests

Use FeatureTestTrait.

Verify:

  • missing required context returns an error
  • invalid school-year ID returns 404 or validation failure
  • unauthorized historical access returns 403
  • closed-year writes return 403
  • active-year writes succeed
  • global endpoints work without school-year parameters
  • conflicting school_year_id and school_year values are rejected
  • endpoint response includes resolved school-year metadata

Finance Regression Tests

At minimum:

  • old-year invoice does not affect current-year balance
  • old-year payment does not affect current-year balance
  • carry-forward creates exactly one target-year record
  • rerunning carry-forward is idempotent or blocked
  • closing preview and closing execution produce matching totals
  • rollback occurs when any closing step fails

Phase 18: Add Static and Runtime Safety Checks

Add a CI script that searches for suspicious global-table filters.

Suggested script:

scripts/check-school-year-filters.php

It should flag patterns such as:

UserModel ... where('school_year'
RoleModel ... where('school_year'
PermissionModel ... where('school_year'
ConfigurationModel ... where('school_year'
students.school_year
families.school_year
parents.school_year
teachers.school_year
staff.school_year

Also verify:

  • all year-owned models contain explicit year filtering methods
  • global models do not contain school-year callbacks
  • every year-owned insert path sets a backend-resolved school year
  • every year-owned mutation invokes the write guard

This check will not prove correctness, but it catches predictable regressions before they become production archaeology.


Phase 19: Database Cleanup Migration

After application code no longer references incorrect columns, create a CodeIgniter migration.

Generate it:

php spark make:migration RemoveInvalidSchoolYearColumns

Suggested migration principles:

  1. Check the table and field before altering it.
  2. Drop indexes or foreign keys first where necessary.
  3. Remove only columns confirmed to be invalid.
  4. Do not remove nullable context columns.
  5. Keep the down() method conservative. Recreating a column does not restore deleted values.

Candidate invalid columns:

school_years.school_year
users.school_year
authorized_users.school_year
roles.school_year
permissions.school_year
role_permissions.school_year
user_roles.school_year
nav_items.school_year
role_nav_items.school_year
settings.school_year
configuration.school_year
preferences.school_year
user_preferences.school_year
email_templates.school_year
attendance_comment_template.school_year
admin_notification_subjects.school_year
login_activity.school_year
ip_attempts.school_year
password_resets.school_year
password_reset_requests.school_year
migrations.school_year
families.school_year
parents.school_year
students.school_year
teachers.school_year
staff.school_year
family_guardians.school_year
family_students.school_year
emergency_contacts.school_year
student_allergies.school_year
student_medical_conditions.school_year
inventory_categories.school_year
inventory_items.school_year
suppliers.school_year
supplies.school_year
supply_categories.school_year
questiontypes.school_year

Migration outline:

<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class RemoveInvalidSchoolYearColumns extends Migration
{
    private array $targets = [
        'users',
        'roles',
        'permissions',
        'configuration',
        'families',
        'students',
        'parents',
        'teachers',
        'staff',
    ];

    public function up()
    {
        foreach ($this->targets as $table) {
            if (
                $this->db->tableExists($table)
                && $this->db->fieldExists('school_year', $table)
            ) {
                $this->forge->dropColumn($table, 'school_year');
            }
        }
    }

    public function down()
    {
        foreach ($this->targets as $table) {
            if (
                $this->db->tableExists($table)
                && ! $this->db->fieldExists('school_year', $table)
            ) {
                $this->forge->addColumn($table, [
                    'school_year' => [
                        'type' => 'VARCHAR',
                        'constraint' => 20,
                        'null' => true,
                    ],
                ]);
            }
        }
    }
}

Before running:

php spark migrate:status
mysqldump --no-data school_view > school_view_schema_before_cleanup.sql
mysqldump school_view > school_view_backup_before_cleanup.sql
php spark migrate
php spark test

Cleanup order:

  1. Audit code references.
  2. Remove invalid model fields and filters.
  3. Deploy application changes.
  4. Back up the database.
  5. Run migration.
  6. Run automated tests.
  7. Run API smoke tests.
  8. Review application logs.

Dropping columns before removing application references is merely debugging with extra property damage.


Phase 20: Migrate From Text to school_year_id

The final design should use foreign keys.

Migration sequence:

  1. Add nullable school_year_id to year-owned tables.
  2. Backfill it from school_year.
  3. Add indexes.
  4. Update models to allow both fields temporarily.
  5. Update writes to set both fields.
  6. Change reads to use school_year_id.
  7. Validate that every year-owned record has a valid ID.
  8. Add foreign keys.
  9. Make school_year_id non-null where ownership is mandatory.
  10. Remove text school_year only after all application and reporting code uses IDs.

Generate migrations:

php spark make:migration AddSchoolYearIdToYearOwnedTables
php spark make:migration BackfillSchoolYearIds
php spark make:migration EnforceSchoolYearForeignKeys

Backfill example:

$this->db->query(
    'UPDATE invoices i
     JOIN school_years sy ON sy.name = i.school_year
       SET i.school_year_id = sy.id
     WHERE i.school_year_id IS NULL'
);

Index example:

$this->forge->addKey('school_year_id');

Foreign key example:

$this->forge->addForeignKey(
    'school_year_id',
    'school_years',
    'id',
    'RESTRICT',
    'CASCADE'
);

Use RESTRICT for deletion unless there is a deliberate archival strategy. Cascading deletion of a school year through finance and academic history would be an impressively efficient catastrophe.


Implementation Order

  1. Confirm the table classification against the actual schema.
  2. Add SchoolYearTableRegistry.
  3. Add SchoolYearContext.
  4. Add SchoolYearContextService.
  5. Register the service in Config\Services.
  6. Add SchoolYearWriteGuard.
  7. Add explicit year-scoping methods to year-owned models.
  8. Remove school-year behavior from global models.
  9. Add repositories/services for identity relationship queries.
  10. Update finance queries.
  11. Update school-year closing preview and execution.
  12. Update academic and attendance queries.
  13. Update parent, family, student, teacher, and staff pages.
  14. Update API routes and controller context resolution.
  15. Update frontend school-year state and endpoint usage.
  16. Add unit, database, feature, and regression tests.
  17. Add static checks for prohibited filters.
  18. Deploy code changes.
  19. Back up the database.
  20. Remove invalid columns through a CI4 migration.
  21. Begin the school_year_id foreign-key migration.

Acceptance Criteria

The implementation is complete only when:

  • No global table is filtered by school_year.
  • No identity table is directly filtered by school_year.
  • Every year-owned read uses validated selected-year context.
  • Every year-owned write forces the year from backend context.
  • Every year-owned write validates that the selected year is writable.
  • Finance totals never mix years unless using an explicit carry-forward record.
  • Closed and archived years reject normal mutations.
  • Context tables remain nullable and are not automatically stamped.
  • Global authentication and configuration work regardless of selected year.
  • Profile identity remains accessible across years.
  • Rosters and assignments change correctly with selected year.
  • APIs return resolved year metadata.
  • Conflicting year parameters are rejected.
  • Tests prove that historical records do not leak into active-year pages.
  • Tests prove that global pages do not break when the selected year changes.
  • No required year-owned endpoint silently returns all rows when context is absent.
  • All dropdown lists are alphabetically ordered where business ordering does not override it.
  • All data tables provide consistent sorting.

Non-Negotiable Rule

Never decide whether to apply school-year filtering merely by checking whether a column exists.

Column existence describes the current schema.

The table registry and domain rules define the intended behavior.