The CI pipeline for our primary PHP monolith was consistently breaching the seven-minute mark for a simple front-end asset build. The application, a sprawling enterprise resource planning system with over a decade of history, was saddled with approximately 80,000 lines of deeply nested, poorly organized Sass. The CSS architecture, once a loose interpretation of BEM, had decayed into a high-specificity quagmire where every change was a gamble against unforeseen side effects. Onboarding new engineers to the front-end was a month-long exercise in archeology. The business demanded faster feature delivery, but our CSS was actively resisting it.
A full rewrite was immediately vetoed as an unacceptable business risk. The alternative—simply trying to enforce stricter Sass discipline—felt like applying a bandage to a compound fracture. It wouldn’t address the fundamental issues of developer velocity and the lack of a coherent design system. This led us to a third, more pragmatic path: a hybrid architecture. The core principle was containment, not replacement. We would freeze the existing Sass codebase, treating it as a legacy dependency, and introduce Tailwind CSS for all new feature development. The challenge was to make these two fundamentally different styling paradigms coexist within a single PHP application without descending into chaos or further degrading performance.
Our initial technical exploration focused on the build process. The application used Laravel Mix, a Webpack wrapper, which gave us a solid foundation. The first objective was to establish a processing pipeline that could handle both .scss
files and Tailwind’s utility generation within a single compilation step.
Here is the initial package.json
that laid the groundwork:
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "mix",
"watch": "mix watch",
"prod": "npm run production",
"production": "mix --production"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"autoprefixer": "^10.4.13",
"axios": "^1.1.2",
"laravel-mix": "^6.0.49",
"lodash": "^4.17.21",
"postcss": "^8.4.21",
"resolve-url-loader": "^5.0.0",
"sass": "^1.58.3",
"sass-loader": "^13.2.0",
"tailwindcss": "^3.2.7"
}
}
The key players are sass
, sass-loader
, tailwindcss
, and postcss
. The initial webpack.mix.js
configuration seemed straightforward. We would have two entry points: one for the legacy Sass and one for the new Tailwind-centric styles.
// webpack.mix.js
const mix = require('laravel-mix');
mix.js('resources/js/app.js', 'public/js')
.sass('resources/sass/legacy.scss', 'public/css') // Entry point for all old Sass
.postCss('resources/css/app.css', 'public/css', [ // Entry point for Tailwind
require('tailwindcss'),
require('autoprefixer'),
])
.version(); // Enable cache-busting
And the corresponding resources/css/app.css
:
@tailwind base;
@tailwind components;
@tailwind utilities;
This immediately created our first major problem. When both legacy.css
and app.css
were loaded on a page, Tailwind’s preflight
base styles aggressively reset browser defaults, which wreaked havoc on our existing Sass components. Buttons lost their padding, form elements were stripped of their styling, and layouts shifted. The legacy CSS was written with the assumption that default browser styles were largely intact.
In a real-world project, you cannot afford to break every existing page. Our solution was to prevent preflight
from being applied globally. Instead, we decided to scope both the base styles and all generated utilities to a specific wrapper class. Any new development would occur within a container element, like <div class="tailwind-scope">...</div>
, isolating the new world from the old.
This was achieved through a critical adjustment in tailwind.config.js
:
// tailwind.config.js
module.exports = {
// Use a selector strategy to prefix all utilities.
// This prevents them from clashing with existing class names.
prefix: 'tw-',
// Crucially, disable preflight globally. We will re-introduce it scoped.
corePlugins: {
preflight: false,
},
content: [
'./app/**/*.php',
'./resources/**/*.blade.php',
'./resources/**/*.js',
],
theme: {
extend: {},
},
plugins: [],
};
With preflight
disabled, we then manually imported it into our main CSS file, but wrapped it inside our chosen scope. This requires a slightly more advanced PostCSS setup.
// webpack.mix.js - revised
const mix = require('laravel-mix');
mix.js('resources/js/app.js', 'public/js')
.sass('resources/sass/legacy.scss', 'public/css/legacy.css') // Keep legacy separate
.postCss('resources/css/main.css', 'public/css/app.css', [
require('tailwindcss/nesting'),
require('tailwindcss'),
require('autoprefixer'),
])
.version();
And the resources/css/main.css
file was changed to implement the scoping:
/* resources/css/main.css */
/*
* This is the magic bullet. We explicitly create a scope for all new
* Tailwind development. This container will be added to the root layout
* of any new pages or refactored sections.
*/
.tailwind-scope {
/*
* Manually include Tailwind's base styles (preflight) but only
* within our designated scope. This prevents it from breaking
* the thousands of lines of legacy SCSS code.
*/
@tailwind base;
}
@tailwind components;
@tailwind utilities;
A common mistake here is to forget the tailwindcss/nesting
plugin or get the order wrong in the PostCSS configuration. Without it, the @tailwind
directive inside a CSS rule will not be processed correctly. This setup provided a stable boundary. Old PHP views would load legacy.css
. New views, or sections of views being refactored, would be wrapped in <div class="tailwind-scope">
and would load both legacy.css
and app.css
.
The next hurdle was PurgeCSS (the engine now integrated into Tailwind’s JIT compiler). The default configuration is naive; it scans files for class-like strings. Our PHP application, however, often constructed class names dynamically. For example, a Blade component for alerts might look like this:
// app/View/Components/Alert.php
namespace App\View\Components;
use Illuminate\View\Component;
class Alert extends Component
{
public string $type;
public string $theme;
// A map to translate a simple type to complex Tailwind classes.
protected array $themes = [
'info' => 'tw-bg-blue-100 tw-border-blue-500 tw-text-blue-700',
'success' => 'tw-bg-green-100 tw-border-green-500 tw-text-green-700',
'warning' => 'tw-bg-yellow-100 tw-border-yellow-500 tw-text-yellow-700',
'danger' => 'tw-bg-red-100 tw-border-red-500 tw-text-red-700',
];
public function __construct(string $type = 'info')
{
$this->type = $type;
$this->theme = $this->themes[$type] ?? $this->themes['info'];
}
public function render()
{
return view('components.alert');
}
}
The corresponding Blade view:
<!-- resources/views/components/alert.blade.php -->
<div class="{{ $theme }} tw-border-l-4 tw-p-4" role="alert">
{{ $slot }}
</div>
Tailwind’s static analysis of alert.blade.php
would only see {{ $theme }}
. It has no knowledge that this variable can resolve to tw-bg-blue-100
, tw-bg-green-100
, etc. Consequently, all those background, border, and text color utilities would be purged from the final app.css
, breaking the component in production.
The pitfall here is to go overboard with safelisting. A broad safelist using regular expressions (e.g., safelist: [{ pattern: /bg-(blue|green|yellow|red)-100/ }]
) can work, but it bloats the CSS and defeats the purpose of purging. A more robust solution is to make the class names discoverable. We refactored the PHP component to include the full class names in a comment block within the Blade file itself, which is a known pattern for informing the purge process.
<!-- resources/views/components/alert.blade.php -->
{{--
Tailwind JIT Safelist:
Classes generated in the PHP component class that need to be preserved.
tw-bg-blue-100 tw-border-blue-500 tw-text-blue-700
tw-bg-green-100 tw-border-green-500 tw-text-green-700
tw-bg-yellow-100 tw-border-yellow-500 tw-text-yellow-700
tw-bg-red-100 tw-border-red-500 tw-text-red-700
--}}
<div class="{{ $theme }} tw-border-l-4 tw-p-4" role="alert">
{{ $slot }}
</div>
This approach keeps the safelisting logic co-located with the component that requires it, improving maintainability. We also had to ensure our tailwind.config.js
content
array was correctly configured to scan all relevant file types, including PHP classes where we might define string-based class lists.
// tailwind.config.js - extended content paths
module.exports = {
// ...
content: [
'./resources/**/*.blade.php',
'./resources/**/*.js',
'./app/View/Components/**/*.php', // Scan PHP View Components
'./app/Helpers/**/*.php', // Scan custom helper files
],
// ...
};
With coexistence and purging solved, the next challenge was establishing a single source of truth for design tokens. The legacy Sass had hundreds of variables for colors, spacing, and fonts.
// resources/sass/_variables.scss
$brand-primary: #3490dc;
$brand-secondary: #6c757d;
$spacing-unit: 8px;
// ... hundreds more
New Tailwind components needed to use these exact same values to maintain visual consistency. Duplicating these values in tailwind.config.js
would be a maintenance disaster. The solution was to create a bridge where Sass generates the design tokens and Tailwind consumes them. We leveraged CSS Custom Properties for this.
First, we created a new Sass file dedicated to exporting variables as CSS Custom Properties.
// resources/sass/design-tokens.scss
// Import the legacy variables
@import 'variables';
// This file is loaded globally and makes our core design system
// available as CSS Custom Properties for any technology to use, including Tailwind.
:root {
--color-brand-primary: #{$brand-primary};
--color-brand-secondary: #{$brand-secondary};
--color-text-default: #{$text-color};
--font-family-sans: #{$font-family-sans-serif};
--font-size-base: #{$font-size-base};
--spacing-1: #{$spacing-unit * 0.5}; // 4px
--spacing-2: #{$spacing-unit}; // 8px
--spacing-3: #{$spacing-unit * 1.5}; // 12px
--spacing-4: #{$spacing-unit * 2}; // 16px
// ... and so on for the entire spacing scale
}
This design-tokens.scss
file was then compiled into its own small CSS file, public/css/tokens.css
, and loaded on every page before any other stylesheet. This ensured the custom properties were available globally.
Next, we configured tailwind.config.js
to reference these CSS Custom Properties instead of defining its own static values.
// tailwind.config.js
const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = {
// ...
theme: {
extend: {
colors: {
// Now, Tailwind's color utilities are powered by our Sass variables.
// e.g., 'tw-bg-brand-primary' will use the value from CSS.
'brand-primary': 'var(--color-brand-primary)',
'brand-secondary': 'var(--color-brand-secondary)',
'text-default': 'var(--color-text-default)',
},
spacing: {
// Rebuild Tailwind's spacing scale using our variables.
'1': 'var(--spacing-1)',
'2': 'var(--spacing-2)',
'3': 'var(--spacing-3)',
'4': 'var(--spacing-4)',
},
fontFamily: {
sans: ['var(--font-family-sans)', ...defaultTheme.fontFamily.sans],
}
},
},
// ...
};
This was a major architectural win. We now had a single source of truth (_variables.scss
) that fed both the legacy and modern front-ends. A change to the primary brand color in Sass would automatically be reflected in any Tailwind utility classes.
The final phase was tackling the abysmal CI build times. The core issue was that on every commit, Webpack was rebuilding the entire asset tree from scratch. This was especially wasteful for the massive legacy.scss
file, which barely changed from month to month.
The optimization strategy was twofold: aggressive caching and intelligent bundle splitting.
First, we enabled Webpack’s filesystem caching in webpack.mix.js
. This is a one-line change that has a profound impact on build times after the initial run.
// webpack.mix.js
const mix = require('laravel-mix');
// ... mix configuration ...
// Enable persistent caching between builds.
// In CI, this requires configuring the cache directory to be persisted
// between pipeline runs.
mix.webpackConfig({
cache: {
type: 'filesystem',
},
});
Second, we solidified our bundle splitting. Instead of one large app.css
, we now had three distinct outputs:
-
tokens.css
: Tiny, generated fromdesign-tokens.scss
. Changes very rarely. -
legacy.css
: Large, generated from the mainlegacy.scss
file. Also changes rarely. -
app.css
: Medium-sized, generated from our Tailwind entry point. Changes with every new feature.
This splitting is highly effective for both CI and browser caching. A change to a single Tailwind component would only invalidate the cache for app.css
, leaving the much larger legacy.css
untouched. The CI build for a typical feature branch went from a full rebuild to a small, incremental compilation.
Here’s the final build process flow visualized:
graph TD subgraph "CI Pipeline Triggered" A[Git Commit] --> B{Run Asset Build}; end subgraph "Legacy Build Path (Cached)" C[design-tokens.scss] --> D[webpack: sass-loader]; D --> E[tokens.css]; F[legacy.scss] --> G[webpack: sass-loader]; G --> H[legacy.css]; end subgraph "Modern Build Path (Incremental)" I[main.css + Tailwind Directives] --> J[webpack: postcss-loader]; K[PHP/JS/Blade files] -- Scans for classes --> J; J --> L[app.css]; end B --> C; B --> F; B --> I; B --> K; M[PHP Asset Manifest] --> N[Load in Blade Layout]; E --> M; H --> M; L --> M; style F fill:#f9f,stroke:#333,stroke-width:2px style C fill:#f9f,stroke:#333,stroke-width:2px style K fill:#ccf,stroke:#333,stroke-width:2px
To manage these separate bundles in our PHP application, we relied on Laravel’s mix manifest. A helper in our base Blade layout would conditionally load the required assets.
// resources/views/layouts/app.blade.php (simplified)
<!DOCTYPE html>
<html>
<head>
<!-- ... -->
<link rel="stylesheet" href="{{ mix('css/tokens.css') }}">
<link rel="stylesheet" href="{{ mix('css/legacy.css') }}">
{{-- Conditionally load the new stylesheet if the page is part of the modernization effort --}}
@if ($usesTailwind ?? false)
<link rel="stylesheet" href="{{ mix('css/app.css') }}">
@endif
</head>
<body>
@if ($usesTailwind ?? false)
<div class="tailwind-scope">
@yield('content')
</div>
@else
@yield('content')
@endif
</body>
</html>
This entire initiative brought our median CI asset build time down from over 420 seconds to just under 60 seconds for most feature branches, as the heavy lifting of compiling legacy.scss
was almost always skipped due to caching. Developer velocity for new features increased demonstrably, as engineers could now build UI with the speed and confidence that Tailwind provides, without fearing the ripple effects of touching the old Sass codebase.
This hybrid architecture is not a permanent destination. It is a transitional state, a carefully constructed bridge designed to carry us from a legacy monolith to a modern front-end stack without disrupting the business. The cognitive load of maintaining two systems is a real cost, and the scoping mechanism requires discipline. The eventual goal remains the slow, methodical strangulation of legacy.css
, component by component, until it can be safely deleted. Future iterations will involve converting the Sass variables themselves into a language-agnostic format like JSON (using Style Dictionary), which would then generate both the .scss
variables and the tailwind.config.js
theme object, fully severing the last dependency.