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:
usersrolespermissionsrole_permissionsuser_rolessettingsconfigurationpreferencesschool_yearslogin_activityip_attemptspassword_resetsmigrationsfamiliesstudentsparentsteachersstaff
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_yearnullable and without a default. notifications,user_notifications, andpayment_notification_logswere verified with no forced school-year value after cleanup.- Some global tables may still physically contain an incorrect
school_yearcolumn 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:
classesclassSectionsectionsenrollmentsstudent_classteacher_classattendance_dataattendance_dayattendance_recordattendance_trackingclass_progress_reportsexam_draftsexamsfinal_examfinal_scoresemester_scoresinvoicesinvoice_installmentspaymentspayment_transactionsmanual_paymentsexpensesrefundscalendar_eventseventsparent_notificationsparent_meeting_schedulesprint_requestsreport_card_acknowledgementswhatsapp_group_linkswhatsapp_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:
usersauthorized_usersrolespermissionsrole_permissionsuser_rolesnav_itemsrole_nav_itemsparent_accountssettingsconfigurationpreferencesuser_preferencesemail_templatesschool_yearsmigrationscachecache_lockssessionspersonal_access_tokenspassword_resetspassword_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:
familiesparentsstudentsteachersstafffamily_guardiansfamily_studentsemergency_contactsstudent_allergiesstudent_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_logscommunication_logsnotificationsnotification_recipientsuser_notificationsmessagessupport_requestscontactusfinance_notification_logspayment_notification_logs
Rules:
school_yearremains 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:
- Read explicit school year from route placeholders or query parameters.
- Support
school_year_idandschool_yearduring the transition period. - Validate that the requested school year exists.
- Validate that the authenticated user may access it.
- Fall back to a session-selected year.
- Fall back to the active year.
- Fail clearly if no valid year can be resolved.
- Return a
SchoolYearContextobject. - 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:
UserModelRoleModelPermissionModelSettingModelConfigurationModelSchoolYearModelStudentModelFamilyModelParentModelTeacherModelStaffModel
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, or404response 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:
-
Year-owned table
Keep the filter, but obtain the value from validated context. -
Global table
Remove the filter and removeschool_yearfrom modelallowedFields. -
Identity table
Replace direct filtering with joins through enrollment or assignment tables. -
Optional-context table
Choose global, global-plus-year, or strict-year behavior based on the endpoint. -
Raw SQL
Add explicit table aliases and scope only the year-owned table. -
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_yearand, during transition,school_year_idin$allowedFields. - Add validation rules where required.
- Add
forSchoolYear()or equivalent explicit methods. - Do not automatically default to the active year inside
beforeInsertunless 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_yearandschool_year_idfrom$allowedFields. - Remove school-year validation rules.
- Remove
beforeFind,beforeInsert, orbeforeUpdatecallbacks that add the active year. - Remove constructor logic reading
configuration.semesterorconfiguration.school_yearunless the model genuinely owns year-specific records.
For optional-context models:
- Keep
school_yearnullable. - 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:
- Current-year balances use only current-year financial records.
- Historical debt does not appear as current-year overdue debt unless an explicit carry-forward transaction exists.
- Carry-forward creates a target-year record. It must not mutate historical invoices.
- Closing previews use the selected year, not whatever year is globally active.
- Every source table in an aggregate query must be reviewed separately.
- Payment transactions and invoice-event links must inherit or explicitly store the same school year as their parent record.
- 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:
studentsandteachersremain 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:
- Profile identity loads by global primary key.
- Year-specific tabs use the selected context.
- Historical profile activity remains accessible.
- A person is not hidden globally merely because they lack an enrollment for the selected year.
- Roster pages may show only identities participating in the selected year.
- 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:
- Year-owned pages send the selected school-year ID.
- Global pages do not depend on school-year parameters.
- Navigation preserves the selected year only where useful.
- Mutation buttons are disabled for read-only years.
- The API still enforces writability even when buttons are disabled.
- Dropdown lists use alphabetical ordering where applicable.
- Tables support sorting consistently.
- 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
404or 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_idandschool_yearvalues 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:
- Check the table and field before altering it.
- Drop indexes or foreign keys first where necessary.
- Remove only columns confirmed to be invalid.
- Do not remove nullable context columns.
- 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:
- Audit code references.
- Remove invalid model fields and filters.
- Deploy application changes.
- Back up the database.
- Run migration.
- Run automated tests.
- Run API smoke tests.
- 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:
- Add nullable
school_year_idto year-owned tables. - Backfill it from
school_year. - Add indexes.
- Update models to allow both fields temporarily.
- Update writes to set both fields.
- Change reads to use
school_year_id. - Validate that every year-owned record has a valid ID.
- Add foreign keys.
- Make
school_year_idnon-null where ownership is mandatory. - Remove text
school_yearonly 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
- Confirm the table classification against the actual schema.
- Add
SchoolYearTableRegistry. - Add
SchoolYearContext. - Add
SchoolYearContextService. - Register the service in
Config\Services. - Add
SchoolYearWriteGuard. - Add explicit year-scoping methods to year-owned models.
- Remove school-year behavior from global models.
- Add repositories/services for identity relationship queries.
- Update finance queries.
- Update school-year closing preview and execution.
- Update academic and attendance queries.
- Update parent, family, student, teacher, and staff pages.
- Update API routes and controller context resolution.
- Update frontend school-year state and endpoint usage.
- Add unit, database, feature, and regression tests.
- Add static checks for prohibited filters.
- Deploy code changes.
- Back up the database.
- Remove invalid columns through a CI4 migration.
- Begin the
school_year_idforeign-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.