Skip to main content

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].js not [name].[hash].js) - No manifest needed, simple git tracking
  • Path aliases - Clean imports like @shared/toast instead 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_modules on 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

  1. Install dependencies:
npm install -D vite sass bootstrap
npm install alpinejs axios
  1. Create directory structure:
mkdir -p src/scss src/js/alpine/{shared,components}
  1. Create vite.config.js (see above)

  2. Create src/js/main.js entry point

  3. Move existing assets to src/

  4. Run the build:

npm run build
  1. Update layout template:
<link rel="stylesheet" href="/dist/style.css">
<script type="module" src="/dist/main.js"></script>
  1. Update .gitignore:
*.js.map
  1. 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

Need help with your project or have questions?

We specialize in AI automation, custom integrations, and intelligent workflows tailored to your business needs.

Whether you need help deploying, building, implementing, or creating a solution - or just want expert guidance on your project - we're here to help.

Contact us today to discuss your project.