Modern Build Tools for Traditional PHP: Getting the Best of Both Worlds
There's a false dichotomy in web development: either you go full SPA with React/Vue and a complex build pipeline, or you stick with "old school" PHP and miss out on modern tooling. But what if you could have server-side rendering, simple deployments, AND optimized assets?
Here's how we integrated Vite into our CakePHP application without overcomplicating things.
The Problem
Traditional PHP applications often serve assets directly:
webroot/
├── css/
│ └── styles.css # Hand-written CSS
└── js/
└── app.js # Unminified JavaScript
Issues:
- No CSS preprocessing (variables, nesting, imports)
- Unminified JS shipped to production
- No tree shaking or dead code elimination
- Bootstrap customization requires editing compiled CSS
- CDN dependencies scattered across templates
Meanwhile, SPA frameworks give you incredible build tooling but add complexity:
- Separate frontend/backend deployments
- API-first architecture required
- SEO challenges
- Longer initial page loads
The Solution: Vite
We wanted:
- Sass for CSS preprocessing
- Bundled, minified JavaScript in production
- All dependencies managed via npm (no CDNs)
- Zero build step on the server
- Keep CakePHP's server-side rendering
The answer: Vite with ES modules.
graph LR
subgraph "Source (src/)"
SCSS[scss/main.scss]
MainJS[js/main.js]
Comp[js/alpine/components/*]
Shared[js/alpine/shared/*]
end
subgraph "npm packages"
Alpine[alpinejs]
Bootstrap[bootstrap]
Axios[axios]
Fuse[fuse.js]
Marked[marked]
end
subgraph "Vite Build"
Vite[Vite + Rollup]
end
subgraph "Output (webroot/dist/)"
CSS[style.css]
JS[main.js]
end
SCSS --> Vite
MainJS --> Vite
Comp --> MainJS
Shared --> MainJS
Alpine --> MainJS
Bootstrap --> MainJS
Axios --> MainJS
Fuse --> MainJS
Marked --> MainJS
Vite --> CSS
Vite --> JS
style Vite fill:#646CFF,stroke:#4B52D0,color:#fff
Project Structure
project/
├── src/
│ ├── scss/
│ │ └── main.scss # SCSS source with Bootstrap
│ └── js/
│ ├── main.js # Single entry point
│ └── alpine/
│ ├── shared/ # Reusable utilities
│ │ ├── api.js
│ │ ├── toast.js
│ │ ├── clipboard.js
│ │ └── bootstrap.js
│ └── components/ # Page components
│ ├── productApp.js
│ ├── searchWidget.js
│ └── ...
├── webroot/
│ └── dist/ # Built output
│ ├── main.js # Bundled JS
│ └── style.css # Compiled CSS
├── templates/ # PHP templates
├── vite.config.js
└── package.json
Key insight: Source files live in src/, a single bundled output in webroot/dist/. PHP templates reference only two files.
Setup
package.json
{
"devDependencies": {
"bootstrap": "^5.3.3",
"sass": "^1.75.0",
"vite": "^7.3.0"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"alpinejs": "^3.15.3",
"axios": "^1.13.2",
"fuse.js": "^7.1.0",
"marked": "^17.0.1"
},
"scripts": {
"dev": "./bin/cake server & vite build --watch",
"build": "vite build"
}
}
Two commands. That's it. All dependencies bundled via npm—no CDN scripts scattered across templates.
vite.config.js
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
// Base path for assets in production
base: '/dist/',
build: {
// Output to webroot/dist
outDir: 'webroot/dist',
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'src/js/main.js'),
},
output: {
// Fixed filenames (no hash) for simple deployments
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]',
},
},
// Single CSS file
cssCodeSplit: false,
// Source maps for debugging
sourcemap: true,
},
// Resolve aliases for clean imports
resolve: {
alias: {
'@': resolve(__dirname, 'src/js'),
'@shared': resolve(__dirname, 'src/js/alpine/shared'),
'@components': resolve(__dirname, 'src/js/alpine/components'),
},
},
// CSS processing
css: {
preprocessorOptions: {
scss: {
includePaths: ['node_modules'],
},
},
},
});
Key configuration choices:
- Fixed filenames (
[name].jsnot[name].[hash].js) - No manifest needed, simple git tracking - Path aliases - Clean imports like
@shared/toastinstead of relative paths - Single CSS file - One request for all styles
- Sourcemaps enabled - Debug in development, stripped in production
The Entry Point: main.js
/**
* Main Entry Point
* Imports all Alpine.js components and shared utilities
*/
// 1. Import Alpine.js
import Alpine from 'alpinejs';
// 2. Import global styles (Vite handles SCSS automatically)
import '../scss/main.scss';
// 3. Import and expose shared utilities
import { AppToast, ToastMixin } from '@shared/toast';
import { AppApi, ApiMixin } from '@shared/api';
import { AppBootstrap, BootstrapMixin } from '@shared/bootstrap';
import { AppClipboard, ClipboardMixin } from '@shared/clipboard';
window.AppToast = AppToast;
window.AppApi = AppApi;
window.AppBootstrap = AppBootstrap;
window.AppClipboard = AppClipboard;
// 4. Import and expose npm dependencies
import * as bootstrap from 'bootstrap';
import axios from 'axios';
import Fuse from 'fuse.js';
import { marked } from 'marked';
window.bootstrap = bootstrap;
window.axios = axios;
window.Fuse = Fuse;
window.marked = marked;
// 5. Import all Alpine components
import '@components/productApp';
import '@components/dashboardWidget';
import '@components/searchWidget';
// ... more components
// 6. Start Alpine
window.Alpine = Alpine;
Alpine.start();
// 7. Initialize Bootstrap tooltips after DOM ready
document.addEventListener('DOMContentLoaded', () => {
AppBootstrap.initTooltips();
});
Benefits of the single entry point:
- One place to see all dependencies
- Vite tree-shakes unused code
- Components register themselves via
alpine:init - Global utilities available to inline Alpine expressions in templates
ES Module Components
Components use standard ES module syntax:
// src/js/alpine/shared/toast.js
import * as bootstrap from 'bootstrap';
export const AppToast = {
show(type, message, duration = 5000) {
// Uses Bootstrap's Toast API
const toast = new bootstrap.Toast(toastEl, {
autohide: true,
delay: duration
});
toast.show();
},
success(message) { return this.show('success', message); },
error(message) { return this.show('error', message); },
};
export const ToastMixin = {
showToast(type, message, duration) {
return AppToast.show(type, message, duration);
}
};
// src/js/alpine/components/searchWidget.js
document.addEventListener('alpine:init', () => {
Alpine.data('searchWidget', () => ({
query: '',
results: [],
async search() {
// Fuse.js available via window.Fuse
const fuse = new Fuse(this.items, { keys: ['title', 'content'] });
this.results = fuse.search(this.query);
}
}));
});
Sass with Bootstrap
// src/scss/main.scss
$primary: #337ab7;
$navbar-nav-link-padding-x: 1rem;
// Import Bootstrap with your customizations applied
@import "bootstrap/scss/bootstrap";
// Your custom styles
.custom-component {
@extend .card;
border-radius: $border-radius-lg;
}
Benefits:
- Customize Bootstrap variables before import
- Use Bootstrap's mixins and functions
- Nesting, variables, and all Sass features
- Single compressed CSS output
The Deployment Story
Here's where this approach shines:
# Development
npm run dev # Starts CakePHP server + Vite watch mode
# Before commit
npm run build # One-time production build
git add .
git commit -m "Update feature"
git push
# Server
git pull # That's it. No npm install. No build step.
We commit built files to git. Controversial? Maybe. But it means:
- No Node.js required on production server
- No build failures during deployment
- No
node_moduleson the server - Works perfectly with simple shared hosting
Gitignore Sourcemaps
Sourcemaps are useful locally but shouldn't go to production:
# .gitignore
*.js.map
You get debugging locally, but production stays lean and source code stays private.
Integration with CakePHP Templates
Templates only need two asset references:
// templates/layout/default.php
<head>
<link rel="stylesheet" href="/dist/style.css">
<script type="module" src="/dist/main.js"></script>
</head>
That's it! All components, utilities, and dependencies are bundled. Individual pages just use Alpine directives:
// templates/Products/index.php
<div x-data="productApp()"
data-items='<?= h(json_encode($products)) ?>'
data-csrf='<?= $this->request->getAttribute('csrfToken') ?>'>
<template x-for="product in items">
<div class="card" x-text="product.name"></div>
</template>
</div>
No per-page script includes. The component is already loaded and registered.
Size Comparison
Real numbers from a production app:
| Asset Type | Output | Gzipped |
|---|---|---|
| JavaScript | ~400 KB | ~80 KB |
| CSS | ~225 KB | ~35 KB |
| Total | ~625 KB | ~115 KB |
This includes:
- Alpine.js
- Bootstrap 5 (JS + CSS)
- Axios
- Fuse.js
- Marked
- All custom components
One request for JS, one for CSS. Both heavily cached.
Development Workflow
sequenceDiagram
participant Dev as Developer
participant Vite as Vite Watch
participant Browser as Browser
participant PHP as CakePHP
Dev->>Vite: npm run dev
activate Vite
loop Development
Dev->>Dev: Edit src/js or src/scss
Vite-->>Vite: Detect change
Vite->>Vite: Rebuild (~50ms)
Dev->>Browser: Refresh
Browser->>PHP: Request page
PHP->>Browser: HTML + /dist/* assets
end
deactivate Vite
Vite rebuilds in ~50 milliseconds. You won't even notice it.
Why Vite Over esbuild Alone?
We previously used esbuild + Sass CLI. Vite builds on esbuild and adds:
| Feature | esbuild + Sass | Vite |
|---|---|---|
| JS bundling | Manual per-file | Automatic tree-shaking |
| CSS processing | Separate Sass CLI | Built-in Sass support |
| Path aliases | Manual | Native resolve.alias |
| Watch mode | Two processes | Single vite build --watch |
| Dependencies | CDN or manual | npm imports, bundled |
| Config | Multiple scripts | Single vite.config.js |
Vite gives us one tool instead of two, with better defaults.
When This Approach Makes Sense
Good fit:
- Content-heavy sites (blogs, e-commerce, SaaS dashboards)
- SEO matters
- Simple hosting (shared hosting, VPS without CI/CD)
- Small teams who don't want to maintain complex tooling
- PHP frameworks (CakePHP, Laravel, Symfony)
Not ideal for:
- Highly interactive SPAs
- Real-time applications
- Teams already invested in React/Vue ecosystem
Comparison with Alternatives
| Approach | Build Complexity | Deployment | SEO | Interactivity |
|---|---|---|---|---|
| Raw PHP + CSS/JS | None | Simple | Great | Limited |
| PHP + Vite | Minimal | Simple | Great | Good (Alpine) |
| PHP API + React SPA | High | Complex | Challenging | Excellent |
| Next.js/Nuxt SSR | High | Complex | Great | Excellent |
The middle ground often gets overlooked. You don't need a SPA framework to have modern tooling.
Getting Started
- Install dependencies:
npm install -D vite sass bootstrap
npm install alpinejs axios
- Create directory structure:
mkdir -p src/scss src/js/alpine/{shared,components}
-
Create vite.config.js (see above)
-
Create src/js/main.js entry point
-
Move existing assets to src/
-
Run the build:
npm run build
- Update layout template:
<link rel="stylesheet" href="/dist/style.css">
<script type="module" src="/dist/main.js"></script>
- Update .gitignore:
*.js.map
- Commit everything (including built files)
Conclusion
You don't have to choose between "modern frontend" and "simple PHP backend." With Vite:
- Sass gives you CSS preprocessing and Bootstrap customization
- ES Modules give you proper import/export and tree-shaking
- npm dependencies replace scattered CDN scripts
- CakePHP gives you server-side rendering and simple deployments
- Alpine.js gives you reactivity without a framework
The result: A fast, SEO-friendly, easy-to-deploy application with optimized assets and modern developer experience.
Two files. Two commands. Zero complexity on the server.
Tech Stack:
- Backend: CakePHP 5.x (or any PHP framework)
- Build Tool: Vite 7.x
- CSS: Sass + Bootstrap 5.x
- JavaScript: ES Modules + Alpine.js 3.x
- Build time: ~50ms