Chapter 27 — System Administration¶
Book 7 · Platform & Admin. Static analysis of the Bizwiz Laravel ERP (multi-tenant Kenyan distributor ERP: web app + mobile APIs + DB writer). Cited as
path:line. No code was executed — this is read-only analysis.
System Administration is the control plane of Bizwiz. It is the biggest single menu in the app and the one every other book depends on. This is where the platform decides who can do what (roles & permissions), which behaviours are switched on for this particular client (settings & feature flags), how the organisation is shaped (branches, departments, projects, GL tags), and how documents get their reference numbers (number series). If another chapter says "this is configurable" or "this is gated by a permission", the switch almost always lives here.
This chapter is anchored on the navigation menu resources/views/admin/includes/sidebar_includes/system_administration.blade.php (736 lines), which is the authoritative index of every screen in the module. The menu groups screens into: General Ledger setup (account sections/groups, sub-account-sections, Dimensions, wallet-matrix, petty-cash types, expense-classifications), System Setup (company-preferences, settings, EOD config, tax/currency/period managers, number-series, roles, employees, loaders, logs, banks, help-desk, incentives), Inventory setup, Route Manager, Receivables/Payables setup, Alerts & Notifications, and Utilities.
1. Purpose¶
Plain language: System Administration is the "settings and setup" area an ERP administrator uses to prepare Bizwiz for a specific distributor. Before a company can trade, someone here defines its branches and departments, sets up the chart-of-accounts scaffolding, chooses which optional modules are turned on, creates the staff logins and the roles that decide what each login can see, and configures the counters that stamp every invoice, payroll run and dispatch with a unique reference number. It is not a day-to-day transactional screen — it is the place you visit when you are onboarding a client, adding a new branch, hiring an employee, or flipping a behaviour on or off.
Technically, the module is a large collection of CRUD ("maintain-a-list") controllers under app/Http/Controllers/Admin/, each backed by a small reference table. There is very little business logic here; the weight of the module is that its output — permission rows, setting rows, dimension rows, number-series counters — is read by every other module. Four data structures dominate:
roles+user_permissions— the RBAC model.settings+administration_settings— the tenant feature-flag / configuration surface.wa_branch/wa_departments/projects/gl_tags— the "Dimensions" that scope and tag data.wa_numer_series_codes— the document-number allocator.
2. Users & roles¶
Plain language: The primary user is a system administrator or an IT/finance super-user. In practice one account — the superuser — can do everything, and everyone else is limited to the specific screens their role grants. Roles are named bundles of permissions (e.g. "Branch Manager", "Cashier"). An administrator uses the Roles screen to build these bundles and the Employees screen to attach a role to each staff login.
The permission / role model (Priority Q1)¶
A user (users table, model app/Model/User.php) has a single role_id. Permissions are not stored per-user by default; they are stored per-role in the user_permissions table and looked up through the role.
rolestable:id, title, slug, timestamps(database/migrations/2023_09_08_134414_create_roles_table.php), later extended withis_hq_role,role_type,app_uuids,sms_mode.user_permissionstable:role_id, user_id, module_name, module_action(database/migrations/2023_09_08_134414_create_user_permissions_table.php:17). Each row is one grant.
The relationship that builds a user's permission set is defined on the User model:
// app/Model/User.php:218
public function rolePermissions() {
return $this->hasMany(UserPermission::class, 'role_id', 'role_id');
}
// app/Model/User.php:223
public function userPermissions() {
return $this->rolePermissions()->where('user_id', $this->id);
}
rolePermissions() joins user_permissions.role_id to the user's role_id — i.e. all rows granted to the user's role. userPermissions() narrows to rows also carrying that user's own user_id (per-user overlays).
How $my_permissions is built (app/helpers.php:1650):
function getPreviousPermissionsArray($user) {
$list = $user->rolePermissions ?? [];
$previous_data = array();
foreach ($list as $data) {
$previous_data[$data->module_name . '___' . $data->module_action] = $data->module_action;
}
return $previous_data;
}
So a permission string is module_name . '___' . module_action. The nav's financial-management___view, roles___approve, account-sections___view etc. are exactly these concatenations. The array is keyed by the string, which is why the entire codebase tests with isset($my_permissions['roles___view']) — presence of the key = granted.
$logged_user_info is produced by getLoggeduserProfile() (app/helpers.php:1035): it takes the Auth user, computes both permission arrays, and stashes them as attributes permissions and user_permissions on the user object (app/helpers.php:1049-1050), then returns the user. Blade views receive $logged_user_info (the user) and $my_permissions (its permissions array).
role_id == 1 = superuser bypass. This is hard-coded throughout:
// app/helpers.php:4202 (getUserPermissions)
if ($logged_user_info->role_id == 1) {
return 'superadmin';
}
// app/Http/Controllers/Controller.php:1118 (mypermissionsforAModule)
if ($logged_user_info->role_id == 1) { return 'superadmin'; }
When the permission set is the literal string 'superadmin', every check short-circuits to "allowed". The nav mirrors this on every line: @if ($logged_user_info->role_id == 1 || isset($my_permissions['…___view'])). Role 1 is the god account; roles 10, 11, 100, 105 are also special-cased (hidden from the Roles list at RoleController.php:48).
The newer can() helper (app/helpers.php:4182) is the modern equivalent used by the Utilities / Alerts sub-menus:
function can(string $action, string $module, ?User $user = null): bool {
$permissions = $user ? getUserPermissions($user) : getUserPermissions();
return (isset($permissions[$module . "___$action"])) || ($permissions == 'superadmin');
}
Same underlying data; can('view','utilities') == isset($my_permissions['utilities___view']) OR superadmin.
Is enforcement real or cosmetic? (Priority Q2 — the recurring guide question)¶
Verdict (code-proven): permission strings ARE enforced — but at the CONTROLLER-METHOD level, NOT via route middleware. They are not merely cosmetic nav-hiding.
Proof of route middleware: the module's routes are grouped with auth/session middleware only, no per-permission gate:
// routes/modules/system_administration.php:41
Route::group(['prefix'=>'admin','namespace'=>'Admin',
'middleware'=>['AdminLoggedIn','ip-blocker','single.session']], …)
AdminLoggedIn (app/Http/Middleware/AdminLoggedIn.php) checks only session validity, idle timeout, and various EnforcementService blocks (petty-cash, LPO, reconciliation, basket duties). It performs no permission check. So the route layer will let any logged-in admin reach a controller.
Proof of controller enforcement (the real gate): every maintained-list controller re-checks the permission inside the action and redirects back with "Invalid Request" if absent. Three independent examples:
// RoleController.php:38-40 (Roles screen)
$permission = $this->mypermissionsforAModule();
if (isset($permission['roles___view']) || $permission == 'superadmin') { … }
else { Session::flash('warning','Invalid Request'); return redirect()->back(); }
// SettingsController.php:33 (Settings screen)
if (isset($permission['settings___view']) || $permission == 'superadmin') { … }
// ApprovalLimitsController.php:23 (Approver Limits)
if (isset($permission['approver-limit___edit']) || $permission == 'superadmin') { … }
So an admin who hand-types /admin/roles without roles___view is bounced back. Caveat for the guide: enforcement is per-controller-method and manual, so coverage depends on the developer remembering to add it. It is consistent across the Admin CRUD controllers sampled, but it is not a blanket middleware guarantee — a controller method that omits the check would be unprotected. Non-view actions (add/edit/delete/approve) are also enforced inside their methods and inside DataTables action-column closures (see RoleController.php:60,69), which is why hiding an edit button and blocking the edit route are two separate checks.
3. Processes¶
Each maintained entity follows the same CRUD lifecycle; the interesting processes are the three that other books consume: granting permissions, reading a feature flag, and allocating a document number.
Process A — Grant permissions to a role¶
flowchart TD
A[Admin opens Roles list<br/>route roles.index] -->|roles___view| B[List of roles]
B --> C[Click lock icon<br/>users.permissions.form/slug]
C --> D["RoleController@getPermissions<br/>render permission matrix"]
D --> E[Admin ticks module___action boxes]
E -->|PATCH users.permissions.updateform| F["RoleController@setPermissions"]
F --> G[Rewrite user_permissions rows<br/>for this role_id]
G --> H[Next login: getPreviousPermissionsArray<br/>rebuilds my_permissions]
H --> I[Nav items appear / controllers allow]
Routes: routes/web.php:952 role-permissions/{slug} (GET form), :955 PATCH setPermissions, :956 POST setPermissionsByUser (per-user overlay). The catalogue of assignable module ⇒ [actions] lives in setUpPermissions() (app/helpers.php:1107+), a large associative array (e.g. 'sales-invoice' => ['view','add','edit',…]). This array is the master list of every permission string the system knows about.
Process B — Read a tenant feature flag (Priority Q3)¶
There are two flag tables and they are read differently:
flowchart LR
subgraph settings table
S1[slug e.g. activate-subbins] --> SH[setting slug helper]
SH --> SV[typed value: bool/number/string]
end
subgraph administration_settings table
A1[slug e.g. use-extensive-gl-posting] --> AH[AdministrationSetting::where slug]
AH --> AV[description value]
end
SV --> USE[branching logic across ERP]
AV --> USE
The setting() helper (app/helpers.php:112) reads the settings table by slug or name, and casts by parameter_type (boolean → filter_var, numeric → float). Example live read in the nav itself: \DB::table('settings')->where('slug','activate-subbins')->value('description') (sidebar…:486) shows/hides the Sub-bin menu.
AdministrationSetting (model app/Models/AdministrationSetting.php, table administration_settings) is the newer slug-based flag store, read directly, e.g. use-extensive-gl-posting:
// app/Services/ExtensiveGlPostingService.php:22
$setting = AdministrationSetting::where('slug','use-extensive-gl-posting')->first();
self::$enabledCache = $setting && in_array((string)$setting->description, ['1','true'], true);
INVENTORY_SETUP (= 'UOM BASED') is read the same way in ~15 places (LocationStoreRepository, WaPosCashSales, WaInternalRequisition, several Exports). getAllAdminSettings() (app/helpers.php:3560) dumps them as name ⇒ description.
Process C — Allocate a document number (Priority Q4)¶
sequenceDiagram
participant Caller as Any module (PM/PAYROLL/FAC/DSP…)
participant Svc as NumberSeriesService::generate(code)
participant DB as wa_numer_series_codes
Caller->>Svc: generate('PM')
Svc->>DB: find row where code='PM'
alt not found
Svc->>DB: create row (last_number_used=0)
end
Svc->>DB: last_number_used + 1, save
Svc-->>Caller: "PM-000123"
NumberSeriesService::generate() (app/Services/NumberSeriesService.php:10) looks up wa_numer_series_codes by code, auto-creates it if missing, increments last_number_used, and returns sprintf("%s-%06d") — this is where series like PM-#####, PAYROLL, FAC, DSP referenced in other books get their padded numbers. The Number Series admin screen (NumerSeriesCodeController, resource route routes/web.php:3479) lets an admin view/edit the counters and starting numbers; Branch Number Series (number-series.branch, :3478) is the per-branch variant. A lost_number_series_codes table (migration 2025_02_11…) tracks gaps.
4. Tables touched & key data¶
| Area | Table(s) | Key columns | Notes |
|---|---|---|---|
| Roles / RBAC | roles, user_permissions |
role_id, user_id, module_name, module_action |
permission string = module_name___module_action |
| Users/Employees (admin) | users |
role_id, restaurant_id (branch), wa_location_and_store_id, wa_department_id |
admin Employees = users (login accounts) |
| Feature flags | settings |
name, slug, description, parameter_type, status, setting_type |
setting() casts by type; setting_type='FEATURE-FLAG' hidden from non-developers (SettingsController.php:47) |
| Feature flags (new) | administration_settings |
name, slug, description, parameter_type, options |
read via AdministrationSetting::where('slug'…) |
| Dimensions | wa_branch, wa_departments, projects, gl_tags |
code, branch/name, description |
org structure that scopes/tags data |
| Number series | wa_numer_series_codes, lost_number_series_codes |
code, module, last_number_used, starting_number, last_date_used, type_number |
document-number allocator |
| Approver limits | approver_limits |
approver_level (string), limit (bigint) |
threshold table |
| Company setup | wa_company_preferences, tax_managers, currency, accounting periods |
— | financial config consumed by GL posting |
| Petty cash / wallet | wallet-matrix, petty_cash_*, expense-classifications |
— | feed Book on petty cash |
5. Interactions with other modules¶
Plain language: almost every other chapter reads from here. A sales invoice asks Number Series for its reference; the GL posting engine asks Administration Settings whether "extensive GL posting" is on; a mobile screen checks the user's role permissions before showing a button; a report filters by branch/department (Dimensions).
- Every module → RBAC:
mypermissionsforAModule()/can()are called at the top of controllers app-wide; the nav for all books gates on$my_permissions.role_id == 1bypasses everywhere. - GL / Finance → Administration Settings:
ExtensiveGlPostingService(used by journal/GL posting) readsuse-extensive-gl-posting; company preferences drive branch-level GL accounts (ExtensiveGlPostingService::companyPreferenceForBranch,:34). - Inventory → Settings:
INVENTORY_SETUP='UOM BASED',activate-subbins,MINIMUM_QUANTITY_OF_CHILD_ITEM,AVERAGE_SALES_DAYS(migration names) branch inventory behaviour. - Sales/Payroll/Dispatch/Fixed-Assets → Number Series:
NumberSeriesService::generate()stamps documents (PM/PAYROLL/FAC/DSP/statutory-payments series, added over time via migrations). - All transactional data → Dimensions: branches (
restaurant_id/wa_branch), departments (wa_department_id), projects & GL tags are attached to bills, expenses, petty-cash (add_project_and_gl_tag_to_bill_and_expensesmigration). - Approvals → Approver Limits:
approver_limits.limitperapprover_levelsets thresholds — ties to the guide's "approvers not curators" theme (approval is a permitted action + a monetary ceiling, not ownership of the record). - Session enforcement:
AdminLoggedIncallsEnforcementServiceto force users into pending petty-cash / LPO / reconciliation / basket tasks before letting them continue — a cross-module "blocking" mechanism configured partly by settings (SESSION_IDLE_MINUTES).
Employees here vs HR employees (Priority Q6)¶
They are different tables/records:
- Admin "Employees" (
route('employees.index')→Route::resource('employees','UserController'),routes/web.php:973) manages theuserstable — i.e. login accounts that carryrole_id, branch, store, and drive permissions. This is what System Administration means by "Employees". - HR "Employees" (
/emp-list→EmployeeController,routes/web.php:722) is the HR employee master (bio-data, experience, payroll) from Book 6 — a distinct record.
A person can therefore exist as an HR employee, a login user, both, or neither. The link is by convention (matching name/badge), not a hard FK visible here. The Employees admin screen also feeds Loaders, User Logs, Login Activity, and mobile-login restrictions.
6. Alternatives & variants¶
- Superuser vs role-based:
role_id==1bypasses all checks; every other role is strictly gated. Special roles 10/11/100/105 are hidden from the Roles list (RoleController.php:48) — reserved/system roles. - Role permissions vs per-user overlay: default grants are per-role (
rolePermissions);setPermissionsByUser(users.permissions.invoice_r,routes/web.php:956) writes rows carrying auser_idfor one-off overrides (userPermissions()). - Two flag stores: legacy
settings(typed,setting()helper) vs neweradministration_settings(read directly). New feature flags trend towardadministration_settings; both coexist.settings.setting_type='FEATURE-FLAG'rows are hidden from non-is_developerusers (SettingsController.php:47) — a dev-only tier of flags. - Number series auto-provision:
generate()silently creates a missing code withtype_number=18(a copied default flaggedTODO: Consult on usageatNumberSeriesService.php:21) — an edge case worth flagging. - Conditional nav: some items appear only when a flag/service says so — e.g. Sub-bins (
activate-subbins), Sevi webhooks (SeviSettlementAvailability::canRegisterWebhooksFromUi(),sidebar…:595). - Two permission dialects: older
isset($my_permissions['x___view'])inline checks vs newercan('view','x'); identical semantics, mixed across the nav.
7. Open questions to confirm¶
Code-proven facts:
- Permission string format module_name___module_action, built by getPreviousPermissionsArray (helpers.php:1650). ✔
- role_id == 1 = superuser bypass (helpers.php:4202, Controller.php:1118, nav everywhere). ✔
- Route middleware for the module is auth/session only — no permission middleware (system_administration.php:41; AdminLoggedIn.php has none). ✔
- Permissions ARE enforced inside controller methods (Roles, Settings, Approver Limits all redirect on failure). ✔
- settings read via setting() (typed); administration_settings read via AdministrationSetting::where('slug'). ✔
- Number allocation via NumberSeriesService::generate on wa_numer_series_codes. ✔
- Admin Employees = users table (UserController), distinct from HR EmployeeController. ✔
Inferences / to validate by a developer:
- Completeness of controller-level enforcement. I proved 3 controllers gate correctly. I did not audit all ~60 admin CRUD controllers — a dev should confirm none of the write/store/update/destroy methods reachable in this module omit the permission check (route layer would not save them).
- Where role permission rows are seeded initially (installer/seeder vs setPermissions UI only) — not traced here.
- Exact roles___approve usage — the string appears in the master permission list; the specific approval flows that consume it live in the approval chapters, not confirmed here.
- Multi-tenancy boundary of settings/administration_settings — whether these tables are per-tenant DB or shared with a tenant column was not verified (no tenant-id column seen on the two flag tables; likely one DB per tenant, to confirm).
- type_number=18 default on auto-created number series — flagged TODO in source; confirm correct series behaviour for auto-provisioned codes.
8. Source references¶
resources/views/admin/includes/sidebar_includes/system_administration.blade.php— full nav (permission gates,role_id==1bypass, conditional flags at :486, :595).app/helpers.php:112setting();:1035getLoggeduserProfile;:1650getPreviousPermissionsArray;:1660getPreviousPermissionsByUserArray;:1107setUpPermissions(master permission catalogue);:3560getAllAdminSettings;:4182can();:4194getUserPermissions.app/Model/User.php:112userRole;:218rolePermissions;:223userPermissions.app/Http/Controllers/Controller.php:1115mypermissionsforAModule;:1126myUserPermissionsforAModule.app/Http/Middleware/AdminLoggedIn.php— session/enforcement middleware (no permission check).app/Http/Controllers/Admin/RoleController.php:38-85(enforcement + DataTables action gating),:103store.app/Http/Controllers/Admin/SettingsController.php:33,47—settings___viewgate + FEATURE-FLAG hiding.app/Http/Controllers/Admin/ApprovalLimitsController.php:23,32— approver-limit gate + update.app/Services/NumberSeriesService.php:10generate().app/Services/ExtensiveGlPostingService.php:19-27—use-extensive-gl-postingflag read.app/Models/AdministrationSetting.php(+ ~15INVENTORY_SETUPreads inLocationStoreRepository,WaPosCashSales,WaInternalRequisition, Exports).- Migrations:
create_roles_table.php,create_user_permissions_table.php,create_settings_table.php,create_administration_settings_table.php(2025_09_23),create_wa_numer_series_codes_table.php,create_wa_branch_table.php,create_wa_departments_table.php,create_projects_table.php,create_gl_tags_table.php,2026_05_19_090720_create_approver_limits_table.php. - Routes:
routes/modules/system_administration.php;routes/web.php:939-987(employees/roles resource),:952-956(role-permissions),:1956settings,:3477-3479number-series,:5987-5988approval-limit.
Summary: System Administration is Bizwiz's control plane — a large set of CRUD reference screens whose output (role permissions, feature flags, dimensions, number-series counters, approver limits) is consumed by every other module; permissions are stored per-role as module_name___module_action strings, enforced inside controller methods (not route middleware) with role_id == 1 bypassing all checks, and tenant behaviour is resolved through the settings and administration_settings flag tables read via the setting() helper and direct AdministrationSetting queries.