48 KiB
Global School-Year Selector and Historical Read-Only Mode — Reviewed Implementation Plan v2
Purpose
Add a global school-year selector to the authenticated CodeIgniter 4 application so a user can:
- See the active school year by default.
- Select another available school year.
- View data belonging to the selected school year across all school-year-aware pages.
- Clearly see READ-ONLY when a non-active school year is selected.
- Never create, update, delete, approve, publish, charge, pay, enroll, assign, or otherwise mutate school-year-owned business data while a non-active school year is selected.
This is not merely a view change. The selected year must control school-year-aware database reads, AJAX/API requests, exports, print views, and year-owned write endpoints. A decorative dropdown that does not change the query scope would be impressively useless.
Current Project Audit
The project already contains part of the required foundation:
app/Support/SchoolYear/SchoolYearContext.phpapp/Services/SchoolYearContextService.phpapp/Services/SchoolYearWriteGuard.phpapp/Models/Concerns/SchoolYearScopedModelTrait.phpapp/Filters/RequireSchoolYearFilter.phpapp/Models/SchoolYearModel.php- school-year lifecycle management under
app/Controllers/Administrator/SchoolYearController.php - an existing page-level filter at
app/Views/partials/academic_filter.php
However, the current implementation is incomplete and inconsistent:
- The application has approximately 372 PHP view files.
- Approximately 229 application views use one of the shared layouts:
layout/management_layoutlayout/main_layoutlayout/main
- Approximately 33 views directly include
partials/academic_filter.php, which would create duplicate school-year selectors after a global selector is added. - Approximately 57 controllers still read the school year directly from session or configuration instead of resolving
SchoolYearContext. - Approximately 63 models reference
school_yearorschool_year_id, but the scoped-model trait is not currently used by those models. RequireSchoolYearFilterexists as an alias but is not broadly applied to the relevant route groups.- There are no dedicated automated tests covering school-year selection, scoping, or historical write protection.
The implementation must consolidate these competing patterns rather than adding a third one.
Additional findings from the review
The first plan was directionally correct, but the project audit exposed several implementation blockers that must be resolved before the selector is released:
BaseController::syncActiveSchoolYearSession()currently removesselected_school_year_idwhenever the configured active year differs from the session. That method would erase a historical selection on the next request and must be removed or replaced.AuthController,SchoolYearManagementService, andSchoolYearClosingServicestill write the active year name into session. The active configuration value may remain a temporary legacy mirror, but it cannot remain a request-time source of truth.- Approximately 30 views contain their own school-year
<select>, in addition to the 33 views usingpartials/academic_filter.php. Removing only the shared partial is insufficient. - Approximately 71 views contain a school-year form control of some kind, including hidden fields used by JavaScript and write forms. Each must be classified as display-only, legacy compatibility, or unsafe posted authority.
- School-year state is also read in services, libraries, listeners, commands, helpers, navbars, and layouts. Migrating controllers and models alone will leave active-year leaks.
Views/partials/navbar.php,Views/layout/main_layout.php, andViews/teacher/teacher_navbar.phpdirectly use the configured active year for teacher assignments, score-card students, and event counts. The global selector would otherwise show one year while the navbar queries another.- Several dependent session values, especially
class_section_id,semester,teacher_scores_selected_semester, andgrading_selected_semester, can become invalid when the selected year changes. - Multiple routes use GET for mutations, including delete, restore, state-change, and recalculation actions. A write guard based only on POST routes would be bypassable.
- The project has CLI commands, scheduled work, event listeners, generated reports, and notification code that cannot use browser session context. They require an explicit school-year contract.
- The current database and model code do not guarantee exactly one active year.
SchoolYearModel::active()silently chooses one row if corruption creates multiple active rows. - Active school-year names are currently editable even though many domain tables store the year as a string. Renaming an in-use year can orphan historical data.
- The proposed rollout originally exposed the selector before completing query migration. That would create pages labeled with a historical year while still returning active-year data. The release order is corrected below.
Review verdict
The feature should proceed, but the selector must not be enabled globally until the route inventory, data-integrity preflight, and read-scope migration are complete. The visible dropdown is the final release switch, not the first implementation milestone.
Required Behavior
Default selection
When no school year has been explicitly selected:
- Resolve the single row in
school_yearswhose status isactive. - Store or expose that row as the current
SchoolYearContext. - Display it as the selected option in the global selector.
- Allow normal read and write behavior.
If no active school year exists, authenticated school-year-aware pages must fail clearly with a configuration error. They must not silently query unscoped data.
Explicit selection
When a user selects a school year:
- Validate the selected
school_year_idagainst theschool_yearstable and the current user access policy. - Store only
selected_school_year_idas the persistent selection. Do not persist a second name value that can become stale. Legacy code must use a compatibility accessor derived fromSchoolYearContext. - When the selected row is the active year, clear the explicit override instead of storing the active ID. This ensures a future activation automatically becomes the default.
- Clear or revalidate dependent session state such as
class_section_idand semester-selection keys. - Redirect back to the originating internal page.
- Resolve all later school-year-aware requests from that session selection until the user changes it or returns to the active default.
Resolution precedence
Use this exact precedence:
- Route-level school-year ID, when a route explicitly owns the context.
- Valid canonical request parameter
school_year_id. - Valid legacy request parameter
school_year; temporarily also recognizeschoolYearandyearonly on inventoried compatibility endpoints. - Valid session
selected_school_year_id. - Active school year.
Conflicting canonical and legacy parameters must return a validation error. Legacy aliases must emit migration telemetry and be removed after their callers are migrated.
Historical read-only mode
For normal application pages, define historical/read-only mode as:
$context->status() !== SchoolYearStatus::ACTIVE
This intentionally treats draft, closing, closed, and archived as non-writable in the global application context. The dedicated school-year lifecycle administration screen is exempt because it already has controlled status-transition actions.
When the selected year is non-active:
- Display a highly visible Bootstrap button-style label:
<span class="btn btn-warning btn-sm disabled" aria-disabled="true">
<i class="bi bi-lock-fill"></i> READ-ONLY
</span>
- Display the selected school-year name beside the label.
- Disable or hide create/edit/delete/import/approve/publish/payment/assignment actions in the UI.
- Reject every school-year-owned write request on the server, even if a user manually submits a request.
- Continue to allow reads, searches, pagination, exports, PDF generation, and printing, provided those operations do not mutate data.
UI disabling is only a convenience. Controller filters, domain-service guards, context-token validation, and record-year ownership checks form the actual protection boundary.
Which School Years Appear in the Dropdown
Normal users
Show:
activeclosingclosedarchived
Do not show draft years to normal users. A draft is not historical school data; it is unfinished administrative state.
Administrators
Administrators may see all statuses, including draft, but selecting a draft through the global selector must still be read-only. Draft editing and activation remain available only on the dedicated school-year management screen.
Ordering and option labels
Order by name DESC, then id DESC.
Recommended labels:
2026-2027 — Active
2025-2026 — Closed
2024-2025 — Archived
The active option should be visually identifiable but must remain a standard accessible <option>.
Scope: Global UI, Selective Data Filtering
The selector should appear on every authenticated application page that uses a normal application layout. That does not mean every database table should be filtered by school year.
School-year-scoped examples
These must use the selected context where the underlying table supports it:
- enrollments
- student-class assignments
- teacher-class assignments
- attendance
- grading and scores
- homework, quizzes, projects, exams, and participation
- invoices, payments, discounts, refunds, and additional charges
- events and event charges
- flags and behavior records
- class preparation and progress
- certificates, badges, and report cards
- financial reports
- school calendar records when year-bound
- inventory movements when explicitly year-bound
Global/master-data examples
These must not be falsely filtered just because the selector is visible:
- users and authentication
- roles and permissions
- global configuration
- static class definitions
- static section definitions
- contact/support content
- email templates
- navigation configuration
- school-year lifecycle management itself
Every action must use the six-category policy defined in the next section. Do not guess based on the page title. Verify the queried tables, side effects, and relationships.
Correct Write-Policy Boundary
The reviewed plan narrows the meaning of read-only. Selecting a historical year must block mutations to school-year-owned business data, not freeze the entire authenticated application.
Classify every action as one of:
GLOBAL_READGLOBAL_WRITESCHOOL_YEAR_READSCHOOL_YEAR_WRITEOPERATIONAL_SIDE_EFFECTSCHOOL_YEAR_LIFECYCLE_ADMIN
Examples:
- Updating a password, user display preference, navigation preference, or global email template is a
GLOBAL_WRITEand is not blocked merely because a historical year is selected. - Editing attendance, scores, invoices, enrollment, class assignments, event charges, or year-owned inventory movements is a
SCHOOL_YEAR_WRITEand must be blocked for every non-active context. - Recording an audit log, blocked-write attempt, download event, or notification-read timestamp may be an
OPERATIONAL_SIDE_EFFECT. It may be allowed only when it does not alter historical business state. - Report-card acknowledgement, regenerated certificates, overwritten PDFs, print logs, and similar read-looking actions must be explicitly classified. If the action changes a year-owned record or replaces a historical artifact, default to blocked.
The UI must combine normal permission checks with school-year writability. Read-only mode does not grant visibility, and normal permissions do not override historical immutability.
Authorization and Accessible-Year Policy
A valid school-year ID is not automatically authorized.
- The resolver must call a policy such as
SchoolYearAccessPolicy::canView($user, $year). - Administrators with the school-year-management permission may see drafts in the global selector; role-name string checks alone are insufficient.
- Parent, student, teacher, and assistant queries must continue enforcing ownership and assignment rules inside the selected year.
- Route parameters and report URLs must pass the same authorization policy as the dropdown.
- Switching roles must revalidate the selected year. A draft selected while acting as administrator must not survive after switching to a non-administrator role.
Whether non-administrators see every non-draft year or only years in which they have a relationship should be decided explicitly. The safer default is to show only authorized years while preserving an administrator view of all years.
Architecture
0. Establish one source of truth and request-scoped context
Before changing views:
- Remove
BaseController::syncActiveSchoolYearSession(). - Stop clearing explicit selection merely because the configured active year differs.
- Treat
school_yearsas the authoritative lifecycle table. - Treat configuration key
school_yearas a temporary compatibility mirror of the active row only. - Deprecate direct reads from
ConfigurationModel,session('school_year'),getSchoolYear(), and controller properties initialized from configuration. - Cache the resolved context only for the current request. Do not cache user selection across users or processes.
- Expose one canonical request variable and one canonical session key:
school_year_idandselected_school_year_id.
The compatibility helper getSchoolYear() may temporarily return service('schoolYearContext')->resolve(...)->yearName() for authenticated HTTP requests, but it must be marked deprecated and removed after migration. CLI code must never depend on that helper.
1. Extend SchoolYearContext
Update:
app/Support/SchoolYear/SchoolYearContext.php
Required behavior:
public function isReadonly(): bool
{
return ! $this->isActive();
}
public function isHistorical(): bool
{
return ! $this->isActive();
}
Keep id(), yearName(), status(), and isExplicitSelection().
Use distinct terminology for global browsing and lifecycle editing:
SchoolYearContext::isReadonly()means non-active years cannot receive normal domain writes.SchoolYearStatus::isLifecycleMetadataLocked()should preserve the lifecycle-specific rule used by school-year administration.
Do not change a shared SchoolYearStatus::isReadonly() method to mean two incompatible things. Draft metadata must remain editable on the dedicated lifecycle screen while draft selection through the global context remains read-only.
The context object is the canonical value. Controllers must stop passing unrelated $schoolYear, $selectedYear, and session/config variants when they all represent the same concept.
2. Extend SchoolYearContextService
Update:
app/Services/SchoolYearContextService.php
Add methods similar to:
public function selectableYears(bool $includeDraft = false): array;
public function select(int $schoolYearId): SchoolYearContext;
public function clearSelection(): void;
public function active(): SchoolYearContext;
Change session resolution so a valid explicitly selected non-active year is retained. The current implementation only accepts a session-selected row when its status is active, which defeats persistent historical browsing.
Required session logic:
$sessionYearId = session('selected_school_year_id');
if (is_numeric($sessionYearId)) {
$row = $this->schoolYearModel->find((int) $sessionYearId);
if ($row !== null && $this->isSelectable($row, $currentUserIsAdmin)) {
return $this->fromRow($row, true);
}
$this->clearSelection();
}
Do not trust the session value merely because it is numeric. Reload and validate the database row on every context resolution.
2A. Enforce active-year and name integrity
SchoolYearModel::active() must not use orderBy(...)->first() as a corruption fallback.
Required behavior:
- Query up to two active rows.
- Return the row only when exactly one active row exists.
- Throw a configuration-integrity exception when zero or multiple active rows exist, except when an explicit authorized historical selection is valid and the page is read-only.
- Protect lifecycle transitions with a transaction and locking.
- Add a database-level single-active guard where supported by the deployed MySQL/MariaDB version, or an equivalent generated-column unique constraint.
- Add a health check confirming the configuration mirror matches the active
school_yearsrow.
School-year names must become immutable once the year has dependent records or has left draft status. Because many legacy tables store school_year as text, renaming an in-use year would disconnect those records. The alternative, a transactional cascade across every year-owned table, is substantially riskier and is not recommended.
2B. Define dependent-session invalidation
When the year changes, remove or revalidate at least:
class_section_id
semester
active_semester
teacher_scores_selected_semester
grading_selected_semester
Any other session key representing a student, class, section, event, report, or filter must be validated against the new year before reuse. A class selected in 2025-2026 cannot be assumed to exist, or be assigned to the same teacher, in 2024-2025.
3. Add a selection controller
Create:
app/Controllers/View/SchoolYearSelectionController.php
Suggested actions:
public function select(): RedirectResponse;
public function reset(): RedirectResponse;
select() must:
- Require authentication.
- Validate CSRF.
- Validate
school_year_idas an existing, authorized, selectable row. - Clear the override when the submitted row is active; otherwise save
selected_school_year_id. - Clear or revalidate dependent session filters.
- Redirect only to a safe internal
return_topath. - Remove stale query keys
school_year_id,school_year,schoolYear, and legacyyearfrom the return URL. - Fall back to the role dashboard when
return_tois invalid.
Never redirect directly to an arbitrary URL supplied by the request. That would turn a school-year selector into an open-redirect feature, because apparently dropdowns also need threat models.
4. Add routes
Add authenticated routes:
$routes->post('school-year/select', 'View\SchoolYearSelectionController::select', [
'filter' => 'auth',
]);
$routes->post('school-year/reset', 'View\SchoolYearSelectionController::reset', [
'filter' => 'auth',
]);
Use the project’s actual authentication filter arguments consistently with existing protected routes.
4A. Add domain-specific exceptions and response mapping
Do not use generic RuntimeException for all school-year failures. Add exceptions for:
- invalid or conflicting selection: HTTP 400
- unknown or unauthorized year: HTTP 404, to avoid leaking inaccessible records
- historical write conflict: HTTP 409
- missing or multiple active rows: HTTP 500 with a safe configuration message for users and detailed server logs
RequireSchoolYearFilter must return JSON only for JSON/AJAX/API requests. Normal HTML requests require an HTML error page or safe redirect. Use one status-code convention throughout the application; do not alternate between 403 and 409 for the same condition.
4B. Add a defense-in-depth write filter
Create a SchoolYearWritableFilter for known school-year mutation routes. It is not a substitute for controller/service guards, but it blocks obvious forged requests before domain work begins.
The filter must be applied regardless of HTTP verb because the current route file contains legacy GET mutations. Those routes should also be converted to POST or DELETE with CSRF protection as part of the migration.
5. Provide shared view data
Create a small service or protected BaseController method that returns:
[
'schoolYearContext' => $context,
'schoolYearOptions' => $years,
'schoolYearReadonly' => $context->isReadonly(),
]
Recommended location:
app/Services/SchoolYearViewDataService.php
Register it in:
app/Config/Services.php
Do not query SchoolYearModel independently from every view. Views should render data, not invent application state.
For authenticated HTML requests, inject this data once through BaseController::initController() into the shared renderer. Use this single approach rather than leaving the implementation split between a controller hook and a filter. Resolve lazily only when user_id exists and the request is an HTML application request.
Avoid resolving school-year context on:
- login and account activation pages
- public landing pages
- password-reset pages
- email rendering
- CLI error views
- API documentation
6. Create the global selector partial
Create:
app/Views/partials/school_year_selector.php
Required elements:
- POST form to
/school-year/select - CSRF field
<select name="school_year_id">- all allowed options
- current request URI in a hidden
return_tofield - auto-submit on change or a clear Apply button
- active status label
READ-ONLYbutton-style label when non-active- accessible label and keyboard behavior
Suggested markup:
<?php if (isset($schoolYearContext, $schoolYearOptions)): ?>
<div class="school-year-context-bar">
<form method="post" action="<?= site_url('school-year/select') ?>" class="d-flex align-items-center gap-2">
<?= csrf_field() ?>
<?php $query = service('request')->getUri()->getQuery(); ?>
<input type="hidden" name="return_to" value="<?= esc(current_url() . ($query !== '' ? '?' . $query : '')) ?>">
<label for="global-school-year" class="form-label mb-0 fw-semibold">
School year
</label>
<select
id="global-school-year"
name="school_year_id"
class="form-select form-select-sm"
onchange="this.form.submit()"
>
<?php foreach ($schoolYearOptions as $year): ?>
<option
value="<?= (int) $year['id'] ?>"
<?= (int) $year['id'] === $schoolYearContext->id() ? 'selected' : '' ?>
>
<?= esc($year['name']) ?> — <?= esc(ucfirst($year['status'])) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($schoolYearContext->isReadonly()): ?>
<span class="btn btn-warning btn-sm disabled" aria-disabled="true">
<i class="bi bi-lock-fill"></i> READ-ONLY
</span>
<?php else: ?>
<span class="badge text-bg-success">ACTIVE</span>
<?php endif; ?>
</form>
</div>
<?php endif; ?>
Preserve relevant existing query parameters when redirecting, but remove stale school_year_id, school_year, schoolYear, and legacy year parameters so the session selection is not immediately overridden by an old URL.
7. Insert the partial into shared layouts
Update:
app/Views/layout/management_layout.phpapp/Views/layout/main_layout.phpapp/Views/layout/main.php
Place the selector immediately below the application navbar and above page content.
Do not add it to:
layout/register_layout.phplayout/email_layout.php- PDFs
- print-only layouts
- public pages
- fragments/modals/partials
This covers most UI pages without editing hundreds of files individually.
8. Remove duplicate page-level school-year selectors
Refactor:
app/Views/partials/academic_filter.php
The partial is currently used by approximately 33 views and combines school year with semester. After the global selector is introduced:
- remove the school-year control from this partial, or
- add a parameter such as
$showSchoolYearControl = falseand default it to false.
Retain page-specific controls such as semester and class section.
All affected controllers must use the global SchoolYearContext instead of trusting $_GET['school_year'] from the old partial.
9. Migrate all custom selectors and shared navigation queries
partials/academic_filter.php is only one source of duplication. The audit found approximately 30 views with a school-year <select>. Each must be reviewed.
High-priority custom selectors include:
- invoice management and financial reports
- report cards and certificates
- enrollment/withdrawal and class assignment
- teacher attendance and grading decision pages
- inventory summaries and reimbursements
- tuition forecast, trophies, events, and class preparation
- parent attendance, scores, enrollment, and invoice pages
Page-specific school-year selectors should be removed when they represent the same global browsing context. A local selector may remain only when the page intentionally compares years or generates a report for a year independent of the global context. Such pages must clearly label the difference and must not silently change the global session.
Also update year-dependent logic in:
app/Views/partials/navbar.phpapp/Views/layout/main_layout.phpapp/Views/teacher/teacher_navbar.php- any navbar, dashboard, counter, badge, or class switcher that currently reads the configured active year
The visible selector, navbar counts, class options, badges, and page data must all use the same request context.
Controller Migration Contract
Every school-year-aware controller action must begin by resolving context:
$schoolYearContext = $this->resolveSchoolYearContext();
$schoolYear = $schoolYearContext->yearName();
$schoolYearId = $schoolYearContext->id();
Read actions
Use the resolved year in every query:
$records = $model
->where('school_year', $schoolYear)
->findAll();
or, once the model trait is adopted:
$records = $model
->forSchoolYear($schoolYearContext)
->findAll();
Pass the same context to the view:
return view('example/index', [
'records' => $records,
'schoolYearContext' => $schoolYearContext,
]);
Write actions
Resolve and guard before validation side effects or database writes:
$schoolYearContext = $this->resolveSchoolYearContext();
$this->assertSchoolYearWritable($schoolYearContext);
Then force the year from context into the payload:
$payload['school_year'] = $schoolYearContext->yearName();
Never accept a posted school_year as authoritative. A user can edit HTML.
Record ownership validation
For updates/deletes, guarding the selected context is insufficient. Also verify that the target row belongs to that context:
$record = $model
->where('id', $id)
->where('school_year', $schoolYearContext->yearName())
->first();
if ($record === null) {
throw PageNotFoundException::forPageNotFound();
}
This prevents an active-year request from modifying a historical row by changing the record ID.
Stale-page and cross-tab protection
Session-wide selection changes affect every browser tab. Every school-year-owned write form and AJAX mutation must include the year context in which the form was rendered:
<input type="hidden" name="school_year_context_id" value="<?= $schoolYearContext->id() ?>">
Before a write, verify that:
- the submitted context ID matches the currently resolved context;
- the context is active;
- the target record belongs to that context.
Reject mismatches with HTTP 409 and ask the user to reload. This prevents a stale form in one tab from writing after another tab changes the global selection.
Redirects and links
Because selection persists in session, ordinary internal links do not need to append the school year. For shareable reports or exports, explicit school_year_id may be included in the URL and must take precedence over session.
Model Migration Contract
Apply SchoolYearScopedModelTrait to models that directly own a school-year column.
Example:
use App\Models\Concerns\SchoolYearScopedModelTrait;
class AttendanceRecordModel extends Model
{
use SchoolYearScopedModelTrait;
}
Do not apply the trait blindly to models that only mention a school year in joined tables or helper methods. Confirm the model’s base table owns either:
school_year_id, orschool_year
Prefer school_year_id for new schema work. Continue supporting name-based legacy tables during migration.
Do not perform database schema introspection inside every forSchoolYear() call. Each scoped model should declare its year column explicitly, for example:
protected string $schoolYearColumn = 'school_year';
The trait should fail loudly when no declared year column exists. Silent guessing or filtering a nonexistent school_year_id column turns a safety mechanism into theater.
Query rule
Every query against a year-owned table must include one of:
WHERE school_year_id = :selected_id:
or:
WHERE school_year = :selected_name:
No school-year-aware list query may rely on the absence of duplicate rows across years.
Join rule
When joining multiple year-owned tables, scope every relevant table or ensure the join keys and source table guarantee the same year. Do not filter one table and assume the others follow politely.
Example:
$builder
->where('student_class.school_year', $year)
->where('teacher_class.school_year', $year);
Services, Libraries, Listeners, CLI, and Scheduled Work
The context migration extends beyond controllers and models.
Audit and update:
GlobalConfigHelper::getSchoolYear()FeeCalculationServiceSemesterScoreService- tuition forecast services
- attendance tracking defaults
SchoolEventListener- all report, notification, email, and calculation services that read active configuration
- CLI commands such as exam-draft reminders and payment notifications
Rules:
- Browser-driven services receive
SchoolYearContextexplicitly from the controller or request-scoped service. - Event payloads include
school_year_idor immutable year name. Listeners must not re-read the current active configuration and assume it matches the event. - CLI commands require
--school-year-idwhen operating on a historical year. Active-year jobs may explicitly resolve the active row, but never use session state. - Queued or deferred jobs must persist the year ID in the job payload. A job executed after the active year changes must still operate on the originally requested year.
- Scheduled jobs that should only affect the active year must assert that policy before writing.
Data-Integrity Preflight and Historical Accuracy
Before enabling the selector, run a read-only audit over every year-owned table:
- null or empty school-year values
- values not present in
school_years - malformed year strings
- duplicate business keys that omit school year
- joins whose related rows belong to different years
- missing indexes on year filters
- rows whose date falls outside the declared school-year range, where that rule applies
Do not silently backfill unknown rows into the active year. Produce an exception report and resolve or quarantine ambiguous records.
The team must also decide what “historical data” means for master data that can change. A historical grade joined to a currently renamed class, section, student, fee rule, or event may not reproduce what users saw at the time. For legally or operationally significant reports, use effective-dated master data or snapshots rather than live mutable joins.
AJAX and API Requests
All page AJAX requests must inherit the current context.
Preferred approach:
- session selection for authenticated same-origin browser requests
- explicit
school_year_idfor report endpoints, exports, and requests that may be opened independently - an explicit
X-School-Year-IDheader or documented query parameter for JWT APIs that do not use browser sessions
The resolver must not treat a posted business-data field as the authoritative context. API context belongs in the route, query, or dedicated header; the business payload is then forced to that resolved context.
Apply schoolYear validation to relevant API/AJAX route groups.
JSON school-year write endpoints must return HTTP 409 Conflict for historical context:
{
"status": 409,
"error": "School year is read-only",
"school_year": "2024-2025"
}
Do not return a successful response with no mutation. Silent failure is not protection; it is future debugging folklore.
Caching, Browser History, and Generated Artifacts
Any server-side or client-side cache containing school-year data must include the selected year ID and user/permission scope in its cache key.
For school-year-aware HTML responses:
- send appropriate HTTP
Cache-Controlheaders, not merely<meta>tags; - test browser back/forward cache behavior after switching years;
- ensure DataTables, AJAX caches, local storage, and saved filters do not reuse active-year responses in a historical context.
Exports and generated files must include the selected year in their filename and metadata. Historical generation must not overwrite an existing artifact for another year. When generation writes an audit log, that log is an allowed operational side effect; when generation updates a year-owned business record or replaces an official historical artifact, it requires explicit policy and normally remains blocked.
Observability and Audit
Log at least:
- year selection and reset events
- invalid, conflicting, or unauthorized year requests
- blocked historical write attempts, including user, route, method, selected year, and target record type/ID when safe
- active-year integrity failures
- uses of legacy configuration/session/request fallbacks during the migration period
Add a temporary migration metric showing how many requests still read the active year from legacy sources. The feature is not done while that count remains nonzero on school-year-aware routes.
UI Read-Only Rules
Create a reusable helper or partial for action availability instead of scattering status comparisons everywhere.
Required UI behavior in historical mode
Disable or omit:
- Create/Add/New buttons
- Edit buttons
- Delete/Archive row actions unrelated to school-year lifecycle
- Save/Submit/Approve/Publish buttons
- payment collection and refund actions
- student enrollment/withdrawal mutation
- class and teacher assignment mutation
- attendance entry or correction
- score entry, grading, and finalization
- imports that write records
- bulk actions
- drag-and-drop mutations
- inline editable controls
Keep available:
- View/details
- filtering/search
- pagination
- sorting
- export
- PDF generation
- audit history
Use actual disabled form controls where possible, but continue enforcing the server guard.
Historical banner
In addition to the compact navbar label, display a page-width warning on application content pages:
<div class="alert alert-warning d-flex align-items-center gap-2" role="alert">
<i class="bi bi-lock-fill"></i>
<div>
You are viewing school year <strong>2024-2025</strong>. This school year is read-only.
</div>
</div>
The warning should be rendered once by the shared layout, not copied into every view.
Exemptions
The global selector and read-only banner must not appear in:
- public landing pages
- login/logout pages
- account activation and password reset
- registration flow before authentication
- email templates
- PDFs and print fragments unless the selected year must be printed as report metadata
- CLI and framework error pages
- Swagger/API documentation
- partial views and modal fragments rendered inside another page
The selector may appear on global authenticated pages, but those pages must not claim their data is filtered when it is not.
Implementation Phases
Phase 0: Inventory, policy, and data preflight
- Produce a route/action inventory with method, controller, queried tables, policy classification, migration status, and test status.
- Inventory custom school-year selectors and hidden form fields.
- Audit controllers, models, services, libraries, listeners, commands, helpers, navbars, and layouts.
- Run the data-integrity preflight and resolve ambiguous/null year data.
- Decide accessible-year policy by role/permission.
- Decide historical snapshot requirements for mutable master data.
- Add a feature flag; keep the global selector hidden in production.
Phase 1: Source of truth and lifecycle integrity
- Remove
BaseController::syncActiveSchoolYearSession(). - Make
school_yearsauthoritative and configuration a compatibility mirror only. - Prevent renaming in-use/non-draft years.
- Enforce exactly one active year with service checks, locking, and a database guard where supported.
- Add domain-specific exceptions and consistent HTML/JSON responses.
- Clear explicit selection on login/account change and revalidate on role switch.
Phase 2: Context, selection, and dependent state
- Change global historical determination to non-active = domain-read-only.
- Preserve separate lifecycle metadata-lock semantics.
- Allow valid authorized non-active session selections.
- Add selectable-year listing through an access policy.
- Add selection and reset methods.
- Selecting active clears the explicit override.
- Add safe redirect validation and strip all legacy year query aliases.
- Clear or revalidate class, section, student, and semester session state.
- Add stale-form context tokens.
Phase 3: Read scoping before public UI
Migrate high-risk domains first:
- financial, invoice, payment, refund, and reimbursement
- enrollment, promotion, student-class, and teacher-class assignment
- attendance, late slips, and dismissals
- grading, scores, decisions, report cards, and certificates
- events, charges, calendar, and communications
- inventory, class preparation, progress, and dashboards
For every action:
- classify it as global/year-read/year-write/operational/lifecycle
- resolve and authorize context
- replace direct configuration/session year reads
- scope base queries, joins, subqueries, unions, counts, and totals
- scope exports, PDFs, print views, dashboard widgets, and DataTables count queries
- scope AJAX child endpoints
- migrate services, listeners, helpers, and calculations used by the action
- add isolation tests before marking the action complete
Phase 4: Write protection and route hygiene
- Add
SchoolYearWritableFilterto known year-owned mutation routes. - Add
assertSchoolYearWritable()at controller and domain-service boundaries. - Verify submitted context token and target-record ownership.
- Force inserted year fields from context.
- Reject posted mismatched year fields.
- Convert legacy GET mutations to POST/DELETE with CSRF.
- Guard bulk actions, imports, background-trigger endpoints, listeners, and commands.
- Define allowed operational side effects and block historical artifact replacement.
Phase 5: Shared UI and custom-selector migration
- Inject request-scoped shared context data.
- Create selector and read-only banner partials.
- Update all shared layouts, navbars, counters, and class switchers.
- Remove or repurpose all duplicate school-year selectors, not only
academic_filter.php. - Disable/hide year-owned write actions while preserving permission checks.
- Add global JavaScript context metadata for AJAX consumers.
- Verify mobile and accessibility behavior.
The global feature flag remains off during this phase.
Phase 6: Caching, observability, and compatibility removal
- Vary caches by year and authorization scope.
- Add HTTP cache headers and browser-history tests.
- Log selections, invalid requests, blocked writes, and active-year integrity failures.
- Add metrics for remaining legacy year-source usage.
- Migrate
schoolYear,school_year,year, and session-name compatibility paths. - Remove the legacy
selected_school_yearname key and direct config/session reads.
Phase 7: Release and regression review
- Run the full required test matrix.
- Verify every inventoried school-year route is migrated or explicitly global.
- Enable the selector in a staging environment.
- Perform cross-tab, back-button, role-switch, and stale-form testing.
- Enable production access by domain or role only after coverage is complete.
- Monitor blocked-write and legacy-fallback metrics.
- Remove the feature flag only after the selector cannot misrepresent query scope.
Required Test Matrix
Context resolution
| Case | Expected result |
|---|---|
| No request/session selection | Active year |
| Valid active session selection | Active year, writable |
| Valid closed session selection | Closed year, read-only |
| Valid archived request ID | Archived year, read-only |
| Invalid request ID | 400 validation response |
| Conflicting ID and name | 400 validation response |
| Deleted session year | Clear session and use active year |
| No active year and no valid selection | Clear configuration error |
Read scoping
Create fixtures with identical student/class/parent relationships in two years and verify:
- active selection returns only active-year rows
- historical selection returns only historical rows
- totals and counts change with selection
- pagination does not mix years
- exports/PDFs use the selected year
- dashboard widgets use the selected year or are explicitly marked global
Write protection
For each high-risk domain, test:
- active year write succeeds with permission
- closed year write fails
- archived year write fails
- closing year write fails
- draft global-context write fails
- manually posted
school_year=activewhile historical context is selected still fails - active context cannot update a historical record by ID
Session and concurrency
Verify:
- selecting the active year clears the explicit override
- activating a new year automatically changes the default for users without an override
- switching year clears or revalidates class and semester state
- switching roles invalidates unauthorized draft selections
- a stale write form opened in another tab fails with 409 after context changes
- browser back/forward navigation does not display a mismatched year and dataset
Non-HTTP execution
Verify:
- CLI commands require or deliberately resolve a year
- queued/deferred work preserves the original year ID after active-year transition
- listeners use the event's year rather than current configuration
- scheduled active-only jobs reject historical writes
Data integrity
Verify:
- multiple active rows cause a hard integrity failure
- an in-use school-year name cannot be changed
- unknown/null legacy year rows are reported rather than silently assigned
- compound uniqueness prevents duplicates across and within years as intended
- cache keys prevent cross-year response reuse
UI
Verify:
- selector appears once, not twice
- active year is default
- selected historical year persists across pages
READ-ONLYlabel and warning appear for non-active years- action buttons disappear or disable
- view/export/print actions remain available
- mobile navbar remains usable
Acceptance Criteria
The feature is complete only when all of the following are true:
- Every authenticated application layout displays one global school-year selector.
- The active year is selected by default.
- A user can select an allowed historical year and the selection persists across navigation.
- Every school-year-aware page reads data only from the selected year.
- Global pages remain global and do not pretend to be year-filtered.
- A non-active year displays a button-style READ-ONLY label and a warning banner.
- No school-year-owned record in a non-active year can be mutated through HTML, AJAX, API, direct URL, forged POST data, record-ID substitution, CLI, listeners, or scheduled work.
- Existing page-level school-year dropdowns are removed or converted to non-year filters.
- Exports, PDFs, print views, totals, and dashboard metrics use the same selected context.
- Automated tests cover resolution, persistence, isolation, and write rejection.
- School-year lifecycle administration continues to work through its dedicated controller and is not blocked by the global read-only UI.
- No query silently falls back to unscoped data when context resolution fails.
- Selecting the active year clears the session override, so future activation changes the default automatically.
- Changing year cannot reuse an invalid class, section, student, or semester selection.
- Global writes and allowed operational logs remain governed by their own permissions and are not accidentally blocked.
- School-year-owned writes are blocked from HTML, AJAX, API, CLI, listeners, scheduled jobs, stale tabs, and forged IDs.
- Exactly one active year is enforced and an in-use year name is immutable.
- No navbar, dashboard counter, class switcher, export, or generated report uses a different year from the visible context.
- All custom school-year selectors are removed, intentionally retained for comparison/reporting, or documented as lifecycle controls.
- Legacy configuration/session/request sources are removed from school-year-aware execution paths.
- The global selector is not released until all exposed routes are scoped and tested.
Definition of Done Checklist by File
Existing files to modify
app/Support/SchoolYear/SchoolYearContext.phpapp/Services/SchoolYearContextService.phpapp/Services/SchoolYearWriteGuard.phpapp/Controllers/BaseController.phpapp/Config/Services.phpapp/Config/Routes.phpapp/Config/Filters.phpapp/Views/layout/management_layout.phpapp/Views/layout/main_layout.phpapp/Views/layout/main.phpapp/Views/partials/academic_filter.phpapp/Views/partials/navbar.phpapp/Views/teacher/teacher_navbar.phpapp/Helpers/GlobalConfigHelper.phpapp/Controllers/AuthController.php- school-year-aware services, libraries, listeners, and commands
- all custom school-year selector views
- all school-year-aware controllers
- all models that directly own a school-year column
- migrations enforcing active-year/name integrity and required indexes/constraints
New files recommended
app/Controllers/View/SchoolYearSelectionController.phpapp/Filters/SchoolYearWritableFilter.phpapp/Policies/SchoolYearAccessPolicy.php- school-year domain exception classes
app/Services/SchoolYearViewDataService.phpapp/Views/partials/school_year_selector.phpapp/Views/partials/school_year_readonly_banner.phptests/Unit/SchoolYearContextTest.phptests/Unit/SchoolYearContextServiceTest.phptests/Feature/SchoolYearSelectionTest.phptests/Feature/HistoricalSchoolYearWriteGuardTest.php- domain-specific school-year isolation feature tests
Final Engineering Rule
There must be exactly one authoritative selected school-year context per request.
The following are not independent sources of truth and must not compete:
- configuration value
- session
school_year - session
selected_school_year - GET
school_year - GET
school_year_id - POST
school_year - controller defaults
- model defaults
All of them must resolve into one validated SchoolYearContext, and every school-year-aware read or write must use that object. Anything less will eventually display one year in the dropdown while querying another year in the database, which is the sort of bug humans call “intermittent” when they mean “we built several sources of truth.”
Review Gate Before Coding
Coding should not begin with the dropdown. The first deliverable is the route/data inventory and the removal of the session-sync behavior that currently destroys historical selection. The second is a working context and write guard proven on one high-risk vertical, preferably invoices or attendance. Only then should the shared layouts expose the selector.
A convincing demo is not “the dropdown changes and one list refreshes.” A convincing demo is: the year changes, every visible count and child request changes with it, historical writes fail through every path, stale tabs are rejected, and returning to active restores normal writes without carrying invalid class or semester state.