Skip to content

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:

  1. roles + user_permissions — the RBAC model.
  2. settings + administration_settings — the tenant feature-flag / configuration surface.
  3. wa_branch / wa_departments / projects / gl_tags — the "Dimensions" that scope and tag data.
  4. 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.

  • roles table: id, title, slug, timestamps (database/migrations/2023_09_08_134414_create_roles_table.php), later extended with is_hq_role, role_type, app_uuids, sms_mode.
  • user_permissions table: 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 == 1 bypasses everywhere.
  • GL / Finance → Administration Settings: ExtensiveGlPostingService (used by journal/GL posting) reads use-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_expenses migration).
  • Approvals → Approver Limits: approver_limits.limit per approver_level sets 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: AdminLoggedIn calls EnforcementService to 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 the users table — i.e. login accounts that carry role_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==1 bypasses 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 a user_id for one-off overrides (userPermissions()).
  • Two flag stores: legacy settings (typed, setting() helper) vs newer administration_settings (read directly). New feature flags trend toward administration_settings; both coexist. settings.setting_type='FEATURE-FLAG' rows are hidden from non-is_developer users (SettingsController.php:47) — a dev-only tier of flags.
  • Number series auto-provision: generate() silently creates a missing code with type_number=18 (a copied default flagged TODO: Consult on usage at NumberSeriesService.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 newer can('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==1 bypass, conditional flags at :486, :595).
  • app/helpers.php:112 setting(); :1035 getLoggeduserProfile; :1650 getPreviousPermissionsArray; :1660 getPreviousPermissionsByUserArray; :1107 setUpPermissions (master permission catalogue); :3560 getAllAdminSettings; :4182 can(); :4194 getUserPermissions.
  • app/Model/User.php:112 userRole; :218 rolePermissions; :223 userPermissions.
  • app/Http/Controllers/Controller.php:1115 mypermissionsforAModule; :1126 myUserPermissionsforAModule.
  • app/Http/Middleware/AdminLoggedIn.php — session/enforcement middleware (no permission check).
  • app/Http/Controllers/Admin/RoleController.php:38-85 (enforcement + DataTables action gating), :103 store.
  • app/Http/Controllers/Admin/SettingsController.php:33,47 — settings___view gate + FEATURE-FLAG hiding.
  • app/Http/Controllers/Admin/ApprovalLimitsController.php:23,32 — approver-limit gate + update.
  • app/Services/NumberSeriesService.php:10 generate().
  • app/Services/ExtensiveGlPostingService.php:19-27 — use-extensive-gl-posting flag read.
  • app/Models/AdministrationSetting.php (+ ~15 INVENTORY_SETUP reads in LocationStoreRepository, 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), :1956 settings, :3477-3479 number-series, :5987-5988 approval-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.