Standardizing Laravel Applications at Northwestern University

20 min read

Northwestern IT maintains numerous Laravel applications, and every new project starts with the same foundational overhead: authentication, authorization, API foundations, audit logging, and administrative interfaces. This is how I eliminated that repetitive grind by building a production-ready starter that ships with everything already wired together.

The Foundational Overhead

Business requirements change project to project, but the foundational infrastructure remains remarkably consistent.

Every project follows a similar pattern:

Manual Setup Checklist

0/7
🏆

If you summoned the effort to click seven checkboxes, kudos. Even that feels like work. Now imagine actually doing all of this: over, and over, and over again.

By the time the foundation is laid, days may have passed without a single line of domain logic written.

Multiply that across a few projects per year, and you're looking at weeks of duplicated effort annually. The work is tedious, but more importantly, error-prone. Configuration drifts across projects. Security implementations vary. Audit requirements are partially implemented. Small inconsistencies compound into maintenance headaches.

The goal was straightforward: eliminate this foundational overhead by packaging our standard stack into a reusable starter that teams can adopt on day one.


What "Production-Ready" Means at Northwestern

Before diving into implementation, let's establish what "production-ready" actually means in Northwestern's context. These weren't aspirational goals, they were hard requirements driven by compliance, security, and operational needs.

🪪 Institutional Identity Integration - Northwestern operates a centralized identity system with NetIDs (unique identifiers for students, faculty, and staff), LDAP directory services, and SSO infrastructure. Applications can't use Laravel's default email/password authentication. They must integrate with Northwestern's SSO, support MFA via Duo, and provision users automatically from the directory.

🔑 Multiple Authentication Methods - Not every user has a NetID. External collaborators, partners, and community members need access to some applications. This means supporting three distinct authentication flows: SSO for internal users, passwordless email-based OTPs for external users, and token-based authentication for service-to-service integrations.

🛡️ Compliance and Audit Requirements - Many Northwestern applications handle data subject to FERPA, HIPAA, or University policies, requiring attestation of compliance controls. This requires comprehensive audit trails showing who did what, when, and from where. Changes to sensitive records need full before/after tracking. API access needs IP restrictions and detailed logging.

🔌 Service-Oriented Architecture - Northwestern's applications don't exist in isolation. They integrate with data warehouses, external SaaS platforms, and other Northwestern services. APIs are first-class interfaces, not afterthoughts. Token management needs to support rotation, expiration notifications, and usage analytics.

⚡ Rapid Admin Development - Building admin interfaces consumes disproportionate development time. User management screens, role editors, audit log viewers, and analytics dashboards are table stakes, but they provide no competitive differentiation. The faster we can build these, the more time we spend on actual features.

These requirements shaped every architectural decision I made in the starter kit.


The Northwestern Laravel Starter

Northwestern Laravel Starter Logo

The Northwestern Laravel Starter is my solution to these requirements: a template repository that consolidates everything into a single foundation. Clone it, configure your environment, run migrations, and you have a functioning application with authentication, authorization, APIs, auditing, and admin tools already operational.

What follows is a detailed examination of each major component: how it works, why it's designed that way, and the tradeoffs involved.

IMPORTANT

The repository is currently internal.

Access is limited to the Northwestern GitHub organization while I finish polishing the codebase and documentation. I expect to make the repository public in the coming weeks.


Dual UI Architecture: Velocity vs. Control

Northwestern requires branded public pages with official colors, typography, and accessibility compliance. However, the administrative interfaces, primarily intended for internal staff, prioritize development velocity over design customization. To support both, the starter employs a dual-stack approach.

Public-Facing UI

Foundation
BladeBootstrap 5Font Awesome
Optimized For
  • Public content & landing pages
  • Complex custom user workflows
  • Official Northwestern branding

Administrative UI

Foundation
FilamentTailwind CSSHeroicons
Optimized For
  • Internal tooling & system config
  • Rapid CRUD operations & tables
  • Analytics & reporting dashboards

Administrative interfaces are where developers lose the most time. Building CRUD screens, data tables, filters, form validation, and analytics dashboards is tedious work that provides no competitive differentiation. Rather than spending days building user management screens, role editors, and audit log viewers, Filament allows you to fluently build them with minimal code.

Isolating Bootstrap and Tailwind

Running Bootstrap and Tailwind in the same application creates namespace conflicts. A utility class like .container means completely different things in each framework. Tailwind's PurgeCSS scanning can't safely analyze Filament's dynamically generated classes.

The solution is complete asset isolation. Northwestern Laravel UI pages load app.css. Filament panels load theme.css. The two CSS bundles never load on the same page. No namespace collisions, no class conflicts, no mysterious styling bugs.

Deep Dive: How the Asset Isolation Works

Separate Vite Entry Points

We configure Vite to treat the two stacks as completely separate builds.

vite.config.js
laravel({
  input: [
    'resources/sass/app.scss', // Bootstrap + Northwestern branding
    'resources/js/app.js', // Public-facing JavaScript
    'resources/css/filament/administration/theme.css', // Tailwind + Filament
  ],
})

Each stack compiles independently. Bootstrap never sees Tailwind templates. Tailwind never processes Bootstrap views.

Template Isolation

Public-facing views live in resources/views/ and extend Northwestern Laravel UI layouts:

@extends('northwestern::purple-container')

@section('content')
    <div class="container">
        <h1 class="page-title">Welcome</h1>
    </div>
@endsection

Filament views live in isolated directories (resources/views/filament/, app/Filament/) and only use Tailwind utilities. Filament's panel provider explicitly declares its custom theme to ensure the admin panel never loads Bootstrap assets.

Scoped Tailwind Scanning

Tailwind v4's @source directive explicitly limits class scanning:

resources/css/filament/administration/theme.css
@import 'tailwindcss';

@source '../../../../app/Filament/**/*';
@source '../../../../resources/views/filament/**/*';
@source '../../../../vendor/filament/**/*';

This tells Tailwind: "Only scan Filament directories." It never touches public views, can't accidentally purge Bootstrap classes, and won't generate conflicting utilities.

What's Included

The Public-Facing UI provides the foundational assets and workflows required for university-compliant user experiences:

  • Authentication Flows - Pre-built views for login method selection and passwordless OTP entry.
  • Branded Assets - Northwestern-compliant layouts for automated emails (OTP codes, alerts) and HTTP XXX error pages.
  • Component Library - A suite of Blade components for navigation and form elements that adhere to institutional accessibility and branding standards.

The Administrative UI provides a centralized control plane for application operations:

  • User Management - View, create, and edit users. Assign roles. Impersonate users. View login history.
  • Role & Permission Management - Create roles with granular permissions. Assign users to roles.
  • API Management - Create API users, generate tokens, configure IP allowlists, and expiration dates. View request logs and analytics.
  • Auditing & Monitoring - Complete audit trail of model changes. HTTP request logging with performance metrics. Real-time analytics dashboards.

Authentication: Three Identity Models

Laravel's default authentication assumes email/password credentials stored in a database.

Our authentication landscape looks completely different:

  • 🔐 Internal users - Authenticate through our identity provider
  • ✉️ External users - Authenticate with email-delivered OTP codes
  • 🔑 Services and integrations - Authenticate with long-lived access tokens

SSO Authentication for Internal Users

Rather than rebuilding SSO integration for every project, the starter uses the northwestern-sysdev/laravel-soa package we already leverage in applications. This package provides first-class support for both Online Passport (agentless SSO with OpenAM/ForgeRock), Microsoft Entra ID, or both simultaneously.

The starter ships preconfigured for Microsoft Entra ID. The package handles session validation, multi-factor authentication with Duo, user provisioning, and secure callback handling. Developers don't write authentication code, they just configure which SSO provider to use.

Automatic User Provisioning

The User model schema aligns with Northwestern's directory structure. The authentication flow implements a JIT strategy to ensure the application acts as a downstream consumer of identity data:

  1. New Users: Upon first login, the user's account is created automatically based on their official directory entry.
  2. Existing Users: Every login acts as a sync event. If attributes change upstream, those updates are immediately reflected in the application to prevent data drift.

SSO Authentication Flow

User

Access App

GET /dashboard (Unauthenticated)

No manual user creation. No data entry forms. No stale profile data. The application's user remains perpetually in sync with the University's system of record.

Passwordless Authentication for External Users

Some applications need to support users outside Northwestern's identity system. These users authenticate via email-based OTPs.

To maintain strict governance, external users cannot self-register; they are provisioned exclusively through the admin panel. An administrator enters the user's email address and demographic information (name, affiliation, etc.). The user can then request an OTP delivered to their email. No passwords to manage, no credential storage, no password reset flows.

This keeps authentication simple for external users while maintaining clear administrative control over who has access.

API Authentication for Machine Identities

API authentication requires more control than Laravel Sanctum provides. Operational requirements demand IP allowlisting, expiration notifications, and detailed request analytics; capabilities that would require fundamental architectural changes to Sanctum.

I built a custom token system. This breaks the "never roll your own auth" rule, but the risk is manageable. The full architecture covering token issuance, rotation, and request logging is detailed in API Infrastructure: When to Roll Your Own.


Authorization: The Permission Enum Pattern

We use Spatie's laravel-permission package for role and permission management, but the real challenge is designing a permission structure that's both flexible and maintainable.

Too granular, and you drown in a checkbox hell of user.edit.email vs user.edit.name. Too coarse, and you can't express real authorization requirements.

Centralizing Permissions with Enums

Instead of scattering permission strings throughout the codebase ('view-users', 'manage_users', 'user.view'), all permissions are centralized in an enum:

// Simplified for illustration
enum PermissionEnum: string
{
    case VIEW_USERS = 'view-users';
    case MANAGE_USERS = 'manage-users';

    public function label(): string
    {
        return match($this) {
            self::VIEW_USERS => 'View Users',
            self::MANAGE_USERS => 'Manage Users',
        };
    }

    public function description(): string
    {
        return match($this) {
            self::VIEW_USERS => 'View user account details and profiles.',
            self::MANAGE_USERS => 'Create, update, and delete user accounts.',
        };
    }
}

This eliminates an entire class of bugs. There's no way to write viwe-users or manage_user if the enum only exposes VIEW_USERS and MANAGE_USERS. Rename a permission, and your IDE refactors every reference automatically. Delete a permission, and your IDE shows every broken reference immediately.

The enum also makes permissions self-documenting. Labels and descriptions live with the permission definition, visible in the admin panel without additional configuration.

Database Synchronization

While the enum defines permissions in code, they still need to exist as database records for laravel-permission to function. The starter uses idempotent seeding to automatically synchronize enum definitions with database records.

Propagation Through the Stack

Permissions cascade naturally through Laravel's authorization layers.

public function viewAny(User $user): bool
{
    return $user->can(PermissionEnum::VIEW_USERS);
}

Reference Data Synchronization: Idempotent Seeding

Laravel applications often need reference data. Lookups, statuses, categories, and similar records that exist in enums or concrete code definitions, but require database records to function.

The challenge is keeping code and the database synchronized across environments without manual intervention.

Traditional Laravel seeders aren't production-safe. Run php artisan db:seed twice and you get duplicate data. Updating a label requires manual database edits in every environment. This works fine for development, but breaks down in production.

The Solution: Idempotent Seeding

The starter implements an IdempotentSeeder, which effectively performs a diff between your code and the database, reconciling differences automatically.

  • Missing records → Created
  • Existing records → Updated with new values
  • Removed from enum → Deleted (respecting the model's deletion strategy)

You can see this logic in action below. Try toggling cases in the enum, then run the seeder to see how the database reacts.

Idempotency Simulator
Dirty
1
enum DocumentStatus: string
2
{
3
4
5
6
7
8
9
}
Changes pending

How It Works

The #[AutoSeed] attribute marks seeders for automatic discovery. When you deploy:

  1. Discovery: The system finds all classes decorated with #[AutoSeed].
  2. Dependency Resolution: It builds a directed dependency graph from dependsOn.
    • If that graph is acyclic (a DAG), the resolver uses DFS to produce a topological order.
    • If seeders form a loop (ABCAA \to B \to C \to A), the resolver detects the circular dependency during traversal and fails fast with the full chain.
  3. Execution: It runs each seeder in the resolved order.

Because this logic is idempotent based on a predetermined slug column, you can include php artisan db:seed --force in your deployment pipeline without fear of duplicating data or causing inconsistencies.

#[AutoSeed(dependsOn: [DocumentTypeSeeder::class])]
class DocumentStatusSeeder extends IdempotentSeeder
{
    protected string $model = DocumentStatus::class;

    protected string $slugColumn = 'slug';

    public function data(): array
    {
        return collect(DocumentStatusEnum::cases())->map(fn ($s) => [
            'slug' => $s->value,
            'label' => $s->label(),
        ])->toArray();
    }
}

No hand-edited database records. No environment-specific SQL. The seeder code is the single source of truth, and rerunning seeders keeps every database in sync automatically.


API Infrastructure: When to Roll Your Own

Northwestern IT operates in a service-oriented environment. Applications integrate with data warehouses, external SaaS platforms, and other Northwestern services. A robust API foundation isn't optional.

Why Custom Token Management?

Sanctum handles token authentication well, but it doesn't meet all our operational needs:

  • IP allowlisting - Restrict tokens to specific networks or CIDR blocks (e.g., "this token only works from the data center")
  • Expiration notifications - Automated emails warning administrators at certain intervals before token expiration
  • Request analytics - Track usage patterns, performance metrics, and endpoint popularity for capacity planning
  • Probabilistic sampling - Configurable logging to manage database growth in high-traffic scenarios

Sanctum could have been extended, but the customization would touch every part of its architecture. Building a purpose-specific token system gives us end-to-end control over issuance, validation, logging, and revocation.

The authentication logic itself is straightforward: validate that the token exists, check if the IP address is allowed, verify expiration, attach the user to the request, and log the access. The implementation is covered by integration tests that verify token validation, IP restriction enforcement, and proper error responses.

Token Architecture

The system separates identity from credentials:

API Users represent machine actors. They have a name ("Data Warehouse Sync"), role(s) (determines permissions), and metadata (created by whom, for what purpose). API users cannot authenticate through the web UI. They exist solely for programmatic access.

Access Tokens are the actual credentials. Each token belongs to one API user, has an optional expiration date, an optional IP allowlist, and a hashed token value. Multiple tokens per API user enable independent rotation without service disruption.

Request Logging and Analytics

Every authenticated API request logs:

  • Token and user identification
  • Unique trace ID for request correlation
  • HTTP status code
  • Route and endpoint details
  • Request duration in milliseconds
  • Response size in bytes
  • User agent and IP address
  • Failure diagnostics when applicable

The administration panel includes an analytics dashboard showing:

  • Request volume trends - API usage over time
  • Status code distribution - Success vs. error rates
  • Slowest endpoints - Performance bottlenecks
  • Top endpoints by volume - Most-used APIs

Default Endpoints

The starter ships with minimal, self-contained API routes:

  • /api/health - Unauthenticated health check
  • /api/v1/me - Authenticated user profile with roles and permissions
  • /api/v1/me/tokens - Token management (list, create, view, delete)

These endpoints are fully functional but intentionally minimal. Real applications add domain-specific endpoints. The foundation (authentication, logging, error handling) is already operational.


Auditing: Two Layers of Accountability

In an enterprise environment, knowing what happened, when it happened, who did it, and what changed is critical. Even when compliance isn't the driver, audit trails prove invaluable for debugging production issues, investigating security incidents, and understanding how data evolved.

The starter implements two layers of auditing: model changes and HTTP requests.

Model Change Auditing

We use owen-it/laravel-auditing for tracking Eloquent model changes. Every create, update, and delete operation logs complete before/after values.

The standard package functionality is excellent, but the starter extends it to capture additional context:

Impersonation Tracking

Changes made while impersonating another user are clearly marked with both the impersonator and the impersonated user. This distinguishes between actions a user took themselves versus actions another user performed on their behalf.

API Trace ID Correlation

Changes made through API requests include the unique trace_id, allowing you to correlate model changes back to specific API calls. If a bulk operation modifies 50 records, you can trace all 50 changes back to the originating API request with a single database query.

Livewire Component Resolution

Eloquent changes originating from Livewire components present a unique observability challenge. In Livewire v2, component updates were routed to /livewire/message/{component-name}, making it immediately clear which component triggered a change. Livewire v3 consolidated all updates to a single /livewire/update endpoint. A sensible architectural decision that simplifies routing and enables request batching, but one that sacrifices visibility.

I extended the auditing system to restore this context by parsing the component name from the Livewire request payload and appending it to the URL stored in audit records (e.g., /livewire/update#user-profile-form).

HTTP Request Auditing

Every HTTP request logs basic information: user, timestamp, route, method, IP address, and user agent. This provides a request-level audit trail separate from model changes.

Combined with model auditing, this gives you complete accountability: "User A made a POST request to /users/123/edit at 2:45 PM from IP 192.168.1.1, which updated the email field from old@example.com to new@example.com."


Lessons Learned: Advice for Building Your Own Starter

🧠 Start with Pain Points, Not Features

Don't build a starter kit with only "cool features." Build it to solve actual repeated pain. Every feature in this starter maps to something we rebuilt multiple times across applications at Northwestern.

If you're building a starter, start with a list of necessary tasks and things you rebuild on every project. Those are your features.

📝 Document Your Decisions

Code lives longer than the people who wrote it. Documentation captures why decisions were made, not just what the code does. This prevents future maintainers from second-guessing design choices and reduces accidental regressions.

🧩 Make It Easy to Diverge

Not every application needs every feature. Design your starter so teams can remove or replace components without breaking the rest of the system.

The starter uses feature flags extensively:

API_ENABLED=false                  # Disables entire API system
LOCAL_AUTH_ENABLED=false           # Removes passwordless authentication
API_REQUEST_LOGGING_ENABLED=false  # Turns off request logging

Teams that need the default behavior get it immediately. Teams with different requirements can disable what they don't need without breaking dependencies.

Feature flags also document which parts of the system are modular and which are foundational. If disabling something breaks the application, it shouldn't be a feature flag.

📐 Optimize for the 80% Case

80% of Northwestern applications need what's in the starter. The other 20% extend or modify it for edge cases. Don't let that 20% bloat your core.

Keep the starter focused on common patterns. If a feature only applies to one or two applications, it belongs in those applications, not the starter.

🛠️ Invest in Developer Experience

Small things matter:

  • Context-aware exceptions - Custom exceptions with actionable messages
  • Clear naming conventions - PermissionEnum::MANAGE_USERS instead of 'users.manage'
  • Consistent response formats - Standardized JSON structure across all endpoints
  • Comprehensive Documentation - Documentation explains not just what code does, but why architectural decisions were made and how edge cases are handled
  • Helpful defaults - Sensible configuration values that work out of the box

These don't add features, but they dramatically reduce cognitive load and make the difference between a starter that teams embrace and one they work around.

🧪 Test Relentlessly

A starter kit with bugs is a liability. Bugs in shared infrastructure multiply across every project using the starter.

Maintain comprehensive test coverage:

  • Unit tests for business logic and utilities
  • Integration tests for authentication flows and API endpoints
  • End-to-end tests for critical user journeys (login, token creation, role assignment)

The starter includes a CI/CD pipeline (GitHub Actions) that runs tests on every commit and blocks merges if tests fail. This catches regressions before they reach projects.

🔁 Dogfood It

A starter kit built in a vacuum is just a collection of guesses. The best way to validate your starter is to use it yourself. If your team isn't using it for real projects, you're not validating it properly.

Recent applications at Northwestern had the opportunity to adopt this starter kit. When rough edges surface, they're fixed in the project and available for other teams to adopt.


Results and ROI

Building the starter kit took me quite some time...

wakatime

I spent months on design decisions, iteration, refinement, documentation, and obsessing over the smallest details. That sounds like a lot of upfront cost, but once you multiply the time savings across multiple projects, the ROI becomes clear.

Beyond Time Savings

Saving hours of development time is great, but the real value of a starter kit lies in standardization:

  • Consistency - Every application uses the same authentication, authorization, and auditing patterns
  • Security - Security improvements can propagate to all projects
  • Onboarding - New developers learn one pattern, then apply it everywhere
  • Institutional Knowledge - Best practices are codified rather than buried in one application

These benefits are harder to quantify but arguably more valuable than time savings.


Conclusion

Building a starter kit is an exercise in identifying patterns. Not every organization needs this specific stack, but every organization building similar applications repeatedly should codify its patterns.

The starter will never be ✨ perfect ✨. Requirements evolve, Laravel releases new versions, dependencies update, and Northwestern's infrastructure changes. Having a shared foundation means improvements happen in one place. Detailed changelogs let teams incorporate upstream features or fixes at their own convenience.

If you're in an environment repeatedly building similar applications, consider extracting your own starter. Start small (authentication and authorization are good entry points), solve real pain, and iterate based on actual use. The investment compounds faster than you'd expect.

Acknowledgements

This starter kit is the result of years of iteration, mistakes, and lessons learned alongside the talented individuals at Northwestern IT.

Special Thanks

  • Northwestern University
    Administrative Systems for championing rigorous process, advocating best practices, and publishing the open-source packages we rely on.
  • Open Source Maintainers for their tireless work.
  • Laravel
    Taylor Otwell for building such a delightful framework.

Help us build the standard

Found a bug? Have an idea for functionality that belongs in the starter? We welcome pull requests.

Contribute on GitHub