You start the Tailwind v4 upgrade on a Laravel app you have shipped in production for a year. The release notes promise a Rust engine and a CSS-first config. The PR diff promises five regressions, a broken dark mode, and a tailwind.config.js you no longer need. Tailwind v4 is a rewrite — new engine, no tailwind.config.js, no PostCSS plugin, renamed gradient classes, renamed shadow scales, and a CSS-first theme system. The official upgrade tool handles ninety percent of it. The remaining ten percent silently breaks your UI.
This is the exact path I took on a production Laravel + Vite + Livewire app. Same order I would do it again. Branch, tool, dependency swap, theme migration, dark mode, class audit, build. Most projects finish in an hour or two.
Create a dedicated upgrade branch and snapshot the current build#
The upgrade tool needs a clean working tree, and you want a quick rollback if a step goes wrong. Cut a branch off main (or develop), run a full production build, and save the manifest so you can diff bundle sizes when you are done. If you have a visual regression suite — Percy, Chromatic, Pest 4 browser tests — generate a baseline now. If you do not, take screenshots of three or four pages that mix forms, gradients, shadows, and dark mode. They become your eyeball regression suite.
git checkout -b chore/tailwind-v4-upgrade
npm run build
cp public/build/manifest.json /tmp/manifest-v3.json
git status # working tree must be clean before the next step
If you already have a Laravel developer toolchain for 2026 wired up with Pest 4 browser tests, this is exactly where they earn their keep. Run them and snapshot the screenshots, because the next step is going to edit your CSS, your Vite config, and every Blade template that uses a gradient or a shadow utility.
Run npx @tailwindcss/upgrade and review the diff#
The upgrade tool is a one-shot CLI maintained by Tailwind Labs. Node 20 or higher, clean git tree, run it once, and read the diff carefully. It parses your Blade templates, your CSS, and your tailwind.config.js, then rewrites in place — class renames, @tailwind directive replacement, @layer utilities → @utility, and extracting theme values out of the JS config into an @theme block.
npx @tailwindcss/upgrade@latest
You will see the tool walk through each file with a progress log, then exit. Open git status — it has touched everything from vite.config.ts to your Blade components. Review the diff before staging anything:
git diff --stat
git diff resources/css/app.css
git diff resources/views/components/
What the tool reliably gets right: @tailwind base/components/utilities → @import "tailwindcss", bg-gradient-to-r → bg-linear-to-r, flex-shrink-0 → shrink-0, custom plugin imports translated into @plugin "@tailwindcss/typography" lines. What it can quietly miss: classes built from dynamic strings, classes inside @apply rules, and anything assembled at runtime by Livewire or Blade. Grep for bg-gradient-to, shadow-sm, flex-grow, and outline-none after the run and fix the remainders by hand.
The tool refuses to run if the working tree is dirty. If you need to override that — say you are running it on top of a partial migration — pass --force. Avoid it unless you understand the trade-off; the tool's confidence comes from being able to roll a single commit back cleanly.
Swap the PostCSS plugin for @tailwindcss/vite#
Tailwind v4 drops PostCSS entirely. The tailwindcss and autoprefixer entries in postcss.config.js are deleted; the new @tailwindcss/vite plugin runs inside Vite directly, which is where the Rust engine lives. The upgrade tool usually rewrites your Vite config, but verify it landed the change you expected.
Edit vite.config.ts so the plugin block looks like this:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
tailwindcss(),
],
});
Then update resources/css/app.css so the very first line is the import, with no @tailwind directives left anywhere:
@import "tailwindcss";
In package.json, remove tailwindcss (v3), @tailwindcss/postcss, and autoprefixer, and add tailwindcss (v4) plus @tailwindcss/vite. The upgrade tool handles this, but a manual check is worth thirty seconds:
npm install
npm uninstall autoprefixer @tailwindcss/postcss
ls node_modules/tailwindcss/package.json && cat node_modules/tailwindcss/package.json | grep '"version"'
If you keep a postcss.config.js with nothing in it, delete the file. Vite will pick up the Tailwind plugin from the JS config and PostCSS will not run at all. The smaller the surface area, the fewer places a build can quietly fall back to v3 behaviour.
Move tailwind.config.js theme values into @theme blocks#
In v3, every brand colour, custom breakpoint, and extended spacing scale lived in tailwind.config.js. In v4, they live in CSS as design tokens inside an @theme block. The upgrade tool extracts what it can find — palette colours, font families, breakpoints — and writes them into app.css. Open the file after the migration and you will see something like:
@import "tailwindcss";
@theme {
--color-brand-50: oklch(0.97 0.02 270);
--color-brand-500: oklch(0.55 0.18 270);
--color-brand-900: oklch(0.22 0.05 270);
--font-display: "Cal Sans", "Inter", sans-serif;
--breakpoint-3xl: 1920px;
}
Every @theme variable doubles as a Tailwind utility. --color-brand-500 becomes bg-brand-500, text-brand-500, border-brand-500. No theme.extend.colors block, no rebuild step to register a class. If you customised the entire palette in v3, you can extend the defaults the same way — the keys mirror the v3 config namespace, just renamed with kebab-case and a --color-, --font-, --spacing- prefix.
If you have a complex tailwind.config.js the tool cannot fully translate — anything with JavaScript logic, plugin registration with custom functions, or safelist patterns — drop it back in temporarily with the compatibility shim:
@import "tailwindcss";
@config "../../tailwind.config.js";
That works, but the v4 docs label it as a v3 compatibility hatch. Migrate the values into @theme when you have a quiet afternoon. JavaScript-driven theme generation is the part the tool cannot help with, and it is the longest tail of a real upgrade.
Fix dark mode by adding @custom-variant#
This is the single most common regression after the upgrade tool runs. darkMode: 'class' does not exist in v4 — you replace it with a one-line @custom-variant declaration in your CSS. If you forget, every dark: utility silently falls back to following the OS prefers-color-scheme, and your toggle button stops doing anything.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
That :where() wrapper keeps specificity at zero, so utilities still beat any component-level CSS you have layered on top. If you toggle a data attribute on <html> instead of a class, use @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));. The exact wiring — Alpine toggle, localStorage persistence, anti-FOUC script — is covered in Tailwind v4 dark mode in Laravel with @custom-variant. Drop it in if your project does not already have it.
Audit class renames the tool missed#
The upgrade tool finds about ninety percent of the class renames. The remaining ten percent live in @apply rules, in classes built at runtime, and in any pattern the tool's parser cannot follow. Grep the project after the migration. Anything that still matches the v3 name is a silent regression waiting for a designer to spot it.
Here are the renames I check by hand every time:
| v3 | v4 | Note |
|---|---|---|
shadow-sm |
shadow-xs |
The whole scale shifted down a step. |
shadow |
shadow-sm |
The unsuffixed default is now shadow-sm. |
bg-gradient-to-r |
bg-linear-to-r |
Renamed for parity with bg-radial/-conic. |
outline-none |
outline-hidden |
Only behaves the old way in forced-colors. |
ring |
ring-3 |
Default ring width shrank from 3px to 1px. |
bg-opacity-50 |
bg-black/50 |
Opacity modifiers replace the old utilities. |
flex-grow |
grow |
Same for flex-shrink-0 → shrink-0. |
Two implicit defaults bite as well. In v3, border defaulted to gray-200; in v4 it defaults to currentColor. Every borderless-looking input that quietly relied on a default grey suddenly inherits your text colour. Placeholder text changed the same way — it was gray-400, now it is currentColor at fifty percent opacity. If you want the old behaviour back, set the variables explicitly:
@theme {
--default-border-color: var(--color-gray-200);
}
Run a project-wide grep before you call this step done:
rg -n 'bg-gradient-to-|shadow-sm|outline-none|flex-shrink|bg-opacity-|text-opacity-' resources/
Fix what survives, commit, move on.
Run the build and visual regression check#
Production build, then a visual diff. If you have Pest 4 browser tests, this is where you replay the baseline screenshots and let the diff tell you what moved. If you do not, walk the three or four pages you snapshotted at the start and look for shadows that have thinned, gradients that have inverted direction, focus rings that have shrunk, and grey borders that have turned black.
npm run build
php artisan test --filter=browser
Compare bundle sizes against the v3 manifest you stashed:
diff <(jq -S . /tmp/manifest-v3.json) <(jq -S . public/build/manifest.json) | head -40
Expect the CSS bundle to shrink — v4 ships less generated CSS by default, and tree-shaking is more aggressive. If it has grown, you have probably left @apply rules in @layer base that v4 evaluates earlier than v3 did. Move the same rules into @layer components or rewrite them as direct utilities. The Pest browser tests catch the visual half; the manifest diff catches the build-pipeline half.
Gotchas and Edge Cases#
A few things outside the tool's reach. The official Tailwind plugins — typography, forms, aspect-ratio — all have v4 builds, but they switch from require() in tailwind.config.js to @plugin "@tailwindcss/typography" inside your CSS. Update them in lockstep with the rest of the upgrade or prose classes stop emitting.
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
Browser targets matter for production. v4 is built for Safari 16.4, Chrome 111, and Firefox 128 and uses modern CSS features — @property, color-mix(), native cascade layers — that older browsers do not have polyfills for. If your analytics show non-trivial traffic on an older Safari, stay on v3 a quarter longer, or feed it a v3-built stylesheet via UA sniffing.
Filament users get the upgrade thrown in. Filament v4 ships with Tailwind v4 out of the box, so panels in a v3-Tailwind project that have not finished the swap will look broken inside an admin view. Upgrade Filament and the rest of the app on the same branch.
@apply inside @layer base and @layer components no longer works the way it did in v3. The fix is usually to move the rule into @utility or rewrite it inline. Livewire components do not need anything special — the markup goes through the same Vite pipeline as the rest of the app — but if you ship JS partials that scan class names, double-check after the upgrade. The same automation discipline I cover in Rector PHP for automated Laravel upgrades applies here: trust the tool, but grep behind it.
Wrapping Up#
Branch, tool, dependency swap, theme, dark mode, class audit, build, ship. The whole upgrade is two to three commits, finishes in an hour or two on a single app, and leaves you with a CSS-first project that no longer needs a tailwind.config.js you maintained for three years. The hour you spend on the visual regression at the end is the one that prevents a designer's Slack message at 22:00.
If you are batching framework upgrades together, run the Laravel 12 to 13 upgrade guide first, then this one, then migrate Livewire 3 to Livewire 4. Each one stands alone but the diffs compound — three small PRs are easier to review and revert than one large one.
FAQ#
How do I upgrade a Laravel project from Tailwind CSS v3 to v4?
Cut a branch, ensure your git working tree is clean and you are on Node 20 or higher, then run npx @tailwindcss/upgrade@latest from the project root. The CLI rewrites your tailwind.config.js, your CSS imports, and your Blade class strings in place. Review the diff, swap tailwindcss and autoprefixer in package.json for the new tailwindcss v4 plus @tailwindcss/vite, run npm run build, and visually regress your key pages.
What does the Tailwind upgrade tool change automatically?
The tool handles three categories: CSS migration (@tailwind base/components/utilities → @import "tailwindcss", @layer utilities → @utility), config migration (theme values and plugin imports from tailwind.config.js extracted into @theme and @plugin directives in CSS), and template migration (class renames in Blade, Vue, JSX, and Svelte). It catches around ninety percent of utility renames. The remaining ten percent live in dynamic class strings, @apply rules, and runtime-built class names, which you grep for after the run.
Where do my tailwind.config.js theme tokens go in v4?
Inside an @theme block in your CSS — usually resources/css/app.css directly after @import "tailwindcss". Tokens become CSS custom properties: --color-brand-500, --font-display, --breakpoint-3xl. Every variable also generates a matching utility class, so --color-brand-500 becomes bg-brand-500, text-brand-500, and border-brand-500 automatically. If you cannot port your JS config in one sitting, the @config "../../tailwind.config.js" compatibility shim lets you keep the v3 file temporarily while you migrate the values across.
Why is the @tailwind base directive removed in v4?
v4 ships as a single CSS layer that imports everything with one statement: @import "tailwindcss". The base, components, and utilities layers are still there, but they are emitted by the import — you do not declare them with @tailwind directives any more. The change makes Tailwind line up with how the rest of CSS works (standard imports rather than custom at-rules) and lets the new Rust engine treat the stylesheet as ordinary CSS that PostCSS does not need to touch.
What gradient class changes do I need to make when upgrading?
The whole bg-gradient-to-* family was renamed to bg-linear-to-* to make room for bg-radial and bg-conic, which are first-class in v4. bg-gradient-to-r becomes bg-linear-to-r, bg-gradient-to-br becomes bg-linear-to-br, and so on. The upgrade tool catches most of these automatically. Grep for bg-gradient-to- after the run to find any that lived inside @apply rules or were built from dynamic strings. The colour-stop utilities (from-, via-, to-) stay the same.
How do I install the new @tailwindcss/vite plugin in Laravel?
Run npm install tailwindcss @tailwindcss/vite after removing the v3 packages, then register the plugin in vite.config.ts alongside laravel-vite-plugin. Import it as import tailwindcss from '@tailwindcss/vite' and add tailwindcss() to the plugins array. Update resources/css/app.css so the first line is @import "tailwindcss" — no @tailwind directives — and delete postcss.config.js if it only existed for Tailwind. npm run dev should boot with the v4 engine and a noticeably faster initial build.