In this article I want to outline how I realised frontend asset builds without the use of NodeJS and NPM.

Frontend Assets


I found scssphp, a PHP-based SASS compiler. Add it to your project with composer require scssphp/scssphp.

Now you can build your CSS like this:

vendor/bin/pscss --style=extended templates/main.scss public/assets/css/main.cssvendor/bin/pscss --style=compressed templates/main.scss public/assets/css/main.min.css

And if you want a shortcut for this, that’s intuitive, you can add a composer script, doing exactly that.

composer.json...  "scripts" : {      "build": [          "vendor/bin/pscss --style=expanded templates/main.scss public/assets/css/main.css",          "vendor/bin/pscss --style=compressed templates/main.scss public/assets/css/main.min.css"      ]  }...

With this in place, you can now build your CSS with composer build.


Another important and handy feature during frontend development is to have a watch command, which re-compiles your assets as soon as a file changes automatically. Of course the Ruby and Node flavours of SASS bring this feature by design.

But even without these installed, there is fswatch which can be installed on many platforms. On macOS I installed it with brew: brew install fswatch. Now I added a second script to composer.json:

composer.json...  "scripts" : {      "build": [          "vendor/bin/pscss --style=expanded templates/main.scss public/assets/css/main.css",          "vendor/bin/pscss --style=compressed templates/main.scss public/assets/css/main.min.css"      ],      "watch": [          "fswatch -0 templates | xargs -0 -n 1 -I {} composer build"      ],  }...

And now, with composer watch you start the watcher. As soon as a file inside the templates folder is edited, the build is triggered.


Now, the purpose of a package manager like NPM is to manage dependencies like pre-made packages and libraries you use in your project. The good news is, since you’re working on a PHP project, you already have a package manager in place. And while most libraries and packages are maintained on Github you can easily install them with Composer.

So let’s say we want to use FontAwesome and Twitter Bootstrap in our project, adding these is straight forward:

composer require fortawesome/font-awesome twbs/bootstrap

Now that both packages have been installed, you can use them in your main SASS file:

templates/main.scss@import "../vendor/twbs/bootstrap/scss/bootstrap.scss";$fa-font-path: "/assets/webfonts" !default;@import "../vendor/fortawesome/font-awesome/scss/fontawesome.scss";

If your watcher is running, all assets will be compiled into CSS in a matter of milliseconds when you save this change to main.scss.

Dependency edge cases

There are some cases, where simply adding a package to your composer.json won’t work as expected. For example: if you add jquery/jquery to your requirements, you will find that there’s no compiled version, no dist folder in this repository. Instead, jQuery has a build process and wants you to npm run build – which is what we try to avoid here.

In this specific case, I would download the desired version directly from the jQuery CDN like this:

composer.json...  "scripts" : {      "build": [          "wget -o public/assets/js/jquery.js",          "wget -o public/assets/js/jquery.min.js",          "vendor/bin/pscss --style=expanded templates/main.scss public/assets/css/main.css",          "vendor/bin/pscss --style=compressed templates/main.scss public/assets/css/main.min.css"      ]  }...

Although most packages on Github have a composer.json in place, you might stumble over a package that doesn’t. In these cases Composer got you covered, all you need to do is to tell Composer where the repository can be found:

composer.json"repositories" : [{  "type" : "package",  "package" : {    "name" : "picocss/pico",    "version" : "1.4.4",    "source" : {      "url" : "",      "type" : "git",      "reference" : "master"    }  }}],

which basically emulates the presence of a composer.json file. Note that the version to be installed, is referenced by the reference parameter which points to a branch, in this case master. After that, you can install it normally with composer require picocss/pico.

Building Javascript

Here’s a basic example on how to, join and minify javascript files with a Symfony style console comand:

src/Build/BuildJsCommand.php<?phpdeclare (strict_types = 1);namespace App\Build;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;final class BuildJsCommand {  public const COMMAND_KEY = 'build:js';  public function build(OutputInterface $out, InputInterface $in): int {    $sourceFiles = [      'public/assets/js/jquery.js',      'templates/main.js'    ];    $minifier = new \MatthiasMullie\Minify\JS();    foreach ($sourceFiles as $file) {      $minifier->addFile($file);    }    $minifier->minify(\sprintf('public/assets/js/main.min.%s.js', (new \DateTimeImmutable())->getTimestamp()));    return 0;  }}

In my opinion, this looks pretty similar to a Grunt or Gulp task – neither more code, nor more complex than the NodeJS equivalent.

After adding this Command to your bin/console like this:

bin/console$app->command(BuildJsCommand::COMMAND_KEY, [    BuildJsCommand::class,    'build']);

you can add this command to your build script in composer:

composer.json...  "scripts" : {      "build": [          "wget -o public/assets/js/jquery.js",          "wget -o public/assets/js/jquery.min.js",          "vendor/bin/pscss --style=expanded templates/main.scss public/assets/css/main.css",          "vendor/bin/pscss --style=compressed templates/main.scss public/assets/css/main.min.css",          "bin/console build:js"      ]  }...

Why choose PHP over NodeJS, webpack, grunt or gulp?

Obviously, these tools are well established and stable. They are specifically made for tasks like: compile assets, copy fonts, join and minify scripts. Besides, they can do so much more, things frontend developers use nowadays.

I chose PHP anyways, and I have to say I’m happy with the result:

  • PHP allows me to do the same things. I work on a PHP project, so using PHP is my first choice.
  • I have less dependencies to worry about. Will a single uglify-es node package still work in a year or two? Or will it be abandoned and needs replacement? Things that that work with PHP today will most likely work in 2-3 years from now, as well.
  • I have less clutter, since my project doesn’t need a package.json, no package-lock.json, no Gruntfile, no node_modules folder and no additional entries to my .gitignore.
  • Dependencies are now in one central place, the vendor folder and not distributed accross node_modules and vendor.
  • Less switching between programming languages (Javascript and PHP) makes my development easier.
  • I am still able to update libraries & dependencies with composer update and I control the versions used in single, central place, the composer.json file.

A word on deploys

When talking about building frontend assets, there’s another topic lurking around the corner: deploys.

Deploy processes, ought to be as lean and compact as possible. The less tasks you need to run during deploy, the faster deploys will be. And more importantly, there’s less risk of errors that might keep you from deploying.

Most frontend assets that I use, need to be built locally, so I see CSS & JS in the browser during development. But there are other assets, like pre-compressed .gz or .br versions of my assets, that shall only exist on the production server. Their only purpose is to reduce server and CPU load and to speed up the server response of my site. Read about this in my article “Pre-compress static assets with Brotli and Gzip” to learn more about the details. In that article I used a Grunt task to create these files.

Now that my build is done with PHP, I could easily compress files with PHP. I decided not to do that. I don’t need these files in my repo nor on my local machine, so instead I outsourced this task to a Github Action – which somewhat violates my lean deploy policy. But hey, Github Actions are really convenient:

.github/workflows/deploy.yml  - uses: stefh/[email protected]    with:      path: ./public      depth: 4      extensions: '.css,.html,.js,.map,.ttf,.otf,.eot,.svg,.woff,.woff2,.xml,.txt,.md'      tools: 'brotli,gzip'

With these 6 lines of YAML instructions, all of the compression previously done with NodeJS is no longer needed and I can focus on more important things.


This approach is fun to work with and actually really really fast. Extending it with very specific, Symfony-style console commands makes it even more powerful. I can’t think of anything I cannot do with PHP that I have been doing with NodeJS. There’s one exception, though. If you use postcss or a postcss plugin, you won’t be able to use these in a PHP-only environment. These are developed for NodeJS, and there simply isn’t a PHP-based tool that can process them.