Case Study:

Modular CMS Engine for Multi-Property Platforms

Project Overview

This is a reusable headless-style CMS built on Laravel and Livewire that has been deployed across multiple client projects. It provides a fully reactive admin panel for managing site content, users, settings, and system configuration — all without page reloads. The frontend is served through a Livewire component layer that reads from the same content models, making the CMS the single source of truth for every public-facing page.

The CMS was developed as a standalone foundation before any project-specific integrations were layered on top. On U2C Mobile it powers the public marketing site, blog, and static pages. The same core has been adapted for other projects involving custom CRMs and content-driven sites, with project-specific modules added without modifying the base CMS architecture.

The Challenge

Reusable across projects. The CMS needed to be portable enough to drop into different projects with different content models, branding, and requirements. This meant keeping the core architecture clean and composable, with no assumptions baked in about the site it runs on.

Multilingual content without duplication. Content had to be manageable in multiple languages without duplicating admin screens or creating a complex translation workflow. The solution needed to feel natural for content editors while remaining extensible — adding a third language should not require code changes.

Settings that propagate without deployment. Logos, analytics IDs, social links, copyright text, and custom scripts are all project-specific and change frequently. These had to be editable from the admin panel and reflect on the live site immediately, with cache invalidation handled automatically.

Maintenance mode that doesn't lock out admins. The site needs to be taken offline for updates without requiring SSH access or environment changes. Admin staff should retain access during maintenance via a secret key stored in the database.

Live reactive admin without heavy JavaScript. All CRUD operations, confirmations, and in-panel notifications had to feel instant and interactive without writing custom JavaScript for every feature. Livewire's component model handles this — each admin screen is a self-contained component with its own state and server-side logic.

Admin Panel Architecture

The admin panel is built entirely in Livewire. Every screen — pages, posts, categories, users, settings, newsletters, contacts — is a standalone Livewire component that renders into the shared layouts.admin layout. Components communicate via Livewire's dispatch() event system rather than page reloads: confirmations open modals, notifications fire Toastr.js alerts, and logo updates propagate to the AdminLogoComponent in the nav bar in real time without refreshing the page.

Dashboard

The admin dashboard provides a live count of four key metrics: total users, total customers, contact form submissions, and newsletter subscribers. Each figure is pulled directly from MySQL at render time with no caching overhead — these are always live.

Consistent CRUD Pattern

Every content management screen follows the same interaction pattern:

  • A paginated index with real-time search and sortable columns (search updates as you type via Livewire's wire:model.live)
  • Create and edit forms open in a modal dispatched via $this->dispatch('show-*-modal') — no separate page navigation
  • Delete triggers a confirmation modal via show-delete-confirmation-modal before any data is removed
  • All mutations dispatch a notification browser event that triggers a Toastr.js toast — success or error — without a flash message page reload
Admin Component Class Key Features
DashboardAdmin\DashboardLive counts: users, customers, contacts, newsletters
PagesAdmin\Page\PageIndex, PageCreate, PageEditSearch, sort, paginate, slug generation, meta description counter, file upload
Blog PostsAdmin\Post\Posts, PostCreate, PostEditPaginated index, featured image upload, meta description, soft-delete
CategoriesAdmin\CategoriesCreate, edit, delete via modal
Home PageAdmin\Home\HomeIndexEdit homepage title, meta, and content; cache-aware save
NewsletterAdmin\NewslettersPaginated subscriber list, unsubscribe action, CSV export (all or date range)
ContactsAdmin\ContactsPaginated contact form inbox
UsersAdmin\UsersCreate/edit/delete users, role assignment, permission-gated edit actions
RolesAdmin\RolesCreate, edit, delete roles; drives user permission hierarchy
SettingsAdmin\SettingsLogos, copyright, GA ID, custom scripts, social media links, cache controls
MaintenanceAdmin\MaintenanceModeToggle Artisan up/down with DB-stored secret bypass key

Content Management

Pages

Static pages (About, Contact, Privacy Policy, Terms, etc.) are managed through PageCreate and PageEdit. Each page has a title, slug, meta description (with a live 155-character counter and overflow warning), rich body content, and an optional file attachment. The ManagesPageContent trait is shared across the Pages and Posts create/edit components — it provides slug auto-generation from the title (Str::slug()), the meta description character counter logic, and file upload helpers (addNewFile, removeNewFile).

Pages are looked up on the frontend by slug and locale via the Page::getCachedPage($slug, $locale) static method, which tries locale-matched records first, falls back to a PageTranslation record, and finally falls back to the English default — so every page has a graceful degradation path if a translation doesn't exist yet.

Blog Posts

Blog posts follow the same create/edit structure as pages. The PostCreate component handles featured image uploads directly via WithFileUploads — images are stored to a dedicated posts disk and referenced via a PostFile pivot model. The public blog index (Front\BlogIndex) uses WithPagination to paginate posts and loads the corresponding blog page metadata from the Page model by slug.

Home Page

The homepage is managed through its own dedicated model (Home) rather than the pages table, since it has a distinct layout and may need separate caching. The HomeIndex component lets the admin edit the homepage title, meta description, and body content. The public HomePage Livewire component calls Home::getCachedHomePage() on mount to serve a cache-warmed response.

Categories

The Categories component manages blog post categories via a full modal-based CRUD flow. Categories are a flat list — create, rename, and delete are all in-panel operations with confirmation gating on delete.

Settings & Site Configuration

The Settings component is the control panel for everything that makes a deployment site-specific. Each settings field is saved independently — changing the GA ID doesn't require re-saving logos, and vice versa. Every save action dispatches a Toastr notification confirming the specific field that changed.

Logo Management

Four logo slots are managed independently: admin panel logo, site header logo, mobile logo, and footer logo. Each uses Livewire's WithFileUploads trait combined with a custom WithStoreFiles trait that handles writing to the images disk and updating the Setting model. After a logo upload, the component dispatches a logoUpdated browser event with the new URL. The AdminLogoComponent — mounted persistently in the admin nav — listens for this event via its $listeners property and updates its own $admin_logo property reactively, so the nav logo changes without a full page reload.

Social Media Links

Social media links are stored in a separate Social model. The settings screen shows all current links as an editable list — each entry has a URL and a Font Awesome icon code (facode). Links can be added, updated inline, or removed. All operations update the Social model directly and dispatch targeted notifications. The social_media_links cache key is invalidated on any change.

Other Settings
  • Copyright text — rendered in the frontend footer via the Setting model.
  • Contact email recipient — the address that receives contact form submissions.
  • Google Analytics ID — injected into the frontend layout head. Changeable without a code deployment.
  • General scripts — a freeform script injection field for third-party tags (chat widgets, pixels, etc.). Rendered just before </body> on the frontend.
Cache Controls

Three manual cache-clear buttons are available in the settings panel: clear homepage cache (homepage_data), clear settings cache (settings), and clear social media cache (social_media_links). These allow admin staff to force a cache refresh after bulk changes without needing terminal access or a deployment.

User & Role Management

The CMS includes a full user management system with role-based access control. Roles are defined in the roles table and managed through the Roles component — any role can be created, renamed, or deleted without a migration or code change.

The Users component handles user creation and editing with inline validation. New users require a confirmed password at creation time; editing an existing user only applies a new password if one is entered. Role assignment is done at create/edit time from a dropdown populated by all available roles.

Permission Hierarchy

The permission system is role-ID based. The Users component enforces edit restrictions at the component level: an Admin (role ID 2) cannot edit a Super Admin (role ID 1) or another user of the same level who isn't themselves. Users at role level 3 or above can only edit their own account. These checks run inside the edit() method before the modal is opened, dispatching a permission-denied notification if the action is blocked.

User Account

The UserAccount component provides each logged-in admin staff member with a self-service screen to update their own profile details independently of the admin user management screen.

Newsletter & Contacts

Newsletter Subscribers

The Newsletters component shows a paginated list of all subscribers with their subscription status. An admin can unsubscribe any address by email lookup directly from the panel. Two export actions are available via Maatwebsite Excel:

  • Export All — downloads all subscribers as a CSV, filename stamped with today's date.
  • Export Date Range — downloads subscribers who signed up within a specified start and end date. The date range is validated before the export runs — start date must not exceed end date, with an inline error flash if it does.

The public NewsletterComponent on the frontend handles new subscriptions reactively — form submission and confirmation are handled in Livewire without a page reload.

Contact Form Inbox

The Contacts component displays a paginated inbox of all contact form submissions. The public-facing ContactPageComponent submits to a Contact model record. The admin inbox is read-only — it serves as a lightweight CRM for incoming enquiries without requiring a third-party service.

Maintenance Mode

The MaintenanceMode component wraps Laravel's built-in Artisan down/up commands in a UI control. Laravel signals maintenance mode by writing a framework/down file to storage — the component checks for this file's existence via file_exists(storage_path('framework/down')) to reflect the current state accurately on every render.

Enabling maintenance mode requires a secret key to be set. The key is validated as required, then saved to the MaintenanceSetting model in the database before Artisan::call('down', ['--secret' => $secret]) is executed. This means admin staff can continue accessing the live site by appending the secret to the URL (?secret=...), without needing SSH access or env changes.

Disabling maintenance mode calls Artisan::call('up') and clears the stored secret from the database. The component re-mounts after both enable and disable actions to reflect the updated state immediately.

Multilingual Frontend

The frontend supports multiple languages through a URL-prefix routing strategy. Adding a new language requires a route group with the new prefix, translation files, and translated page records — no core code changes are needed.

Locale Detection

The SetLocale middleware reads the first URL segment on every request. If the segment is es, Laravel's application locale is set to Spanish. Any other value defaults to English. Admin, customer portal, API, and Livewire internal paths are in an explicit exclusion list and always run in English, ensuring the middleware has no impact on backend routes.

Page Translation Model

Pages and their translations are resolved through a three-step lookup in Page::getCachedPage($slug, $locale):

  1. Find a Page record with the exact slug and requested locale.
  2. If not found, check the page_translations table for a PageTranslation record with that slug and locale. If found, overlay the translation fields (title, slug, meta description, content) onto the parent page object.
  3. Fall back to the English page record. If an associated translation exists in its translations relationship, overlay it. Otherwise serve the English content.

This three-tier fallback means the site never returns a 404 due to a missing translation — English content serves as a safety net while translations are being added progressively.

Language Switcher

The LanguageSwitcher Livewire component reads the current locale from app()->getLocale() on mount and renders the appropriate toggle in the nav. Switching language navigates to the locale-prefixed equivalent of the current URL.

Frontend Page Rendering

The PageComponent Livewire component handles all slug-based public pages. On mount it calls Page::getCachedPage($slug, $locale) with the current locale, aborts with 404 if no page is found, and populates component properties from the resolved page record. It then renders the Blade view matched to the page's original_slug — allowing a translated URL (e.g. /es/sobre-nosotros) to resolve to the same Blade template as its English counterpart (about-us).

Translation Strings

All UI strings in Blade templates use Laravel's __() helper. Translation files live in lang/en/ and lang/es/. Adding a new language only requires a new translation file and a new /locale/ route group — the middleware and model resolution layer handles the rest automatically.

Frontend Architecture

Every public-facing page is a dedicated Livewire component in the App\Livewire\Front namespace. Components mount by resolving their content from the database (with caching where appropriate), and render into layout templates that receive global site data — settings and social media links — as layout parameters.

Layout Separation

Frontend layouts are separate from the admin layout. Each front component specifies its layout on render: layouts.frontend.home, layouts.frontend.pages, layouts.frontend.blog, etc. Global data (site settings, social links) is passed as layout variables rather than queried inside each Blade view, keeping templates clean and data access centralised in the component layer.

Frontend Components

Beyond the content pages, the front layer includes interactive Livewire components that handle user-facing actions reactively:

  • NewsletterComponent — handles newsletter signup with live validation and in-place confirmation
  • ContactPageComponent — contact form submission with server-side validation
  • BlogIndex / SingleBlogComponent — paginated blog listing and individual post view
  • FaqPageComponent — FAQ page with expandable accordion items
  • LanguageSwitcher — locale-aware nav toggle
  • StateDropdown / PlanDropdowns — reactive dropdowns for plan selection and state filtering
Caching Strategy

Frequently accessed content is cached at the model layer. Home::getCachedHomePage() and Page::getCachedPage() store results in Laravel's cache with keys like homepage_data and page:{slug}:{locale}. Cache is invalidated from the admin settings panel via manual clear buttons, or automatically on content save where relevant. This keeps frontend page loads fast while admin changes propagate on the next cache miss.

Tech Stack

Layer Technology
FrameworkLaravel 12 (PHP 8.3)
Reactive UILivewire 3.4
FrontendBootstrap 5.2, Sass/SCSS, Vite 5
AuthLaravel Breeze
DatabaseMySQL 8 (Docker)
NotificationsToastr.js (via Livewire browser events)
ExportMaatwebsite Excel (CSV)
File UploadsLivewire WithFileUploads + custom WithStoreFiles trait
i18nLaravel locale system + PageTranslation model + SetLocale middleware
TestingPestPHP

Outcome

The CMS is production-deployed and has been reused across multiple projects. Content editors can manage all site pages, blog posts, branding, analytics, and system settings without developer involvement. The multilingual architecture has been validated in production with English and Spanish, and the three-tier page resolution model means new translations can be added incrementally without breaking existing content.

The Livewire component model keeps the admin panel fully interactive — modals, confirmations, live search, file uploads, and inline notifications all work without custom JavaScript per feature. The same pattern has been carried forward to every project built on this foundation, making the codebase immediately familiar to anyone who has worked with it before.

The maintenance mode system, cache management controls, and role-based user system give operations teams autonomy over their deployments without requiring server access, reducing the operational overhead for each project the CMS is used on.