shyim/sasso-ffi
PHP FFI bindings for libsass that let you compile SCSS/Sass stylesheets directly from PHP without a native extension, offering a lightweight, fast option for asset pipelines and build tooling.
shyim/php-sasso-ffi is a thin FFI binding around Sasso, a Rust SCSS compiler that targets Dart Sass compatibility. It is not a Laravel package — there is no service provider, no config file, no facade. You wire it up yourself, which makes the first ten minutes the most important part of the evaluation.
1. Confirm FFI is actually available. This is the gate that kills the package on shared hosting and some managed PHP runtimes:
php -i | grep -i 'ffi.enable'
php -r 'var_dump(extension_loaded("ffi"));'
You need ffi.enable=1 (dev/CLI) or ffi.enable=preload (production — which then requires an actual ffi.preload file, so don't set this casually). If FFI is compiled out (--disable-ffi) or disabled by policy, stop here and use scssphp/scssphp or a dart-sass binary invoked via Process.
2. Install and allow the Composer plugin. As of 0.1.1 the binary is fetched by shyim/composer-binary-downloader, a Composer plugin. Composer will refuse to run it unless you allow it, and if it doesn't run you get a "library not found"-shaped failure at runtime rather than at install time:
{
"require": { "shyim/php-sasso-ffi": "^0.1.1" },
"config": {
"allow-plugins": { "shyim/composer-binary-downloader": true }
}
}
Then composer install and watch the output — you want to see the downloader resolve a platform binary. If it silently does nothing, that's your bug, not the compiler's.
3. Look at vendor/shyim/php-sasso-ffi/src/ first, not the README. The package is at 0.1.x with a single public API surface; the constructor/compile method signature is the ground truth and is small enough to read in a minute. Check how the library path is resolved (env var vs. hardcoded vendor/.../lib), because that determines your Docker and deploy story.
4. First use case — a throwaway compile in Tinker:
$sasso = new \Sasso\Sasso(); // verify exact class/signature in src/
echo $sasso->compileFile(base_path('resources/scss/app.scss'));
If that prints CSS, you're done with setup. Everything after this is caching, error handling, and deciding when it runs.
Compile at build time, not request time. The realistic production pattern is an Artisan command invoked from CI or a deploy hook, so the web tier never touches FFI:
class BuildStylesheets extends Command
{
protected $signature = 'css:build';
public function handle(): int
{
$sasso = new \Sasso\Sasso();
$css = $sasso->compileFile(resource_path('scss/app.scss'), [
// style: compressed, load paths, etc. — confirm option shape in src/
]);
File::put(public_path('css/app.css'), $css);
return self::SUCCESS;
}
}
This sidesteps every FFI-in-FPM risk and makes the compile artifact reviewable in a diff.
If you must compile at runtime, wrap it in a cache keyed on source mtimes. A stat-walk over resources/scss/**/*.scss plus the entry file, hashed with md5_file, is cheap and correct:
$key = 'scss:'.md5(collect(File::allFiles(resource_path('scss')))
->map(fn ($f) => $f->getMTime())->implode(','));
return Cache::rememberForever($key, fn () => $sasso->compileFile(...));
Bind the compiler as a singleton in AppServiceProvider so you pay the library-load cost once per process, and flush the cache tag on deploy.
Resolve @use/@import from node_modules or a shared partials dir. Sasso follows Dart Sass load-path semantics; collect your search roots explicitly rather than relying on CWD, which differs between CLI, queue workers, and FPM:
$loadPaths = [resource_path('scss'), base_path('node_modules')];
Surface compiler errors as file+line, not as a stack trace. Sass errors are user errors. Catch the package's exception type, extract the span, and rethrow as something Laravel's debug page renders legibly (ValidationException-style, or a custom exception implementing render()). In production, log and fall back to the last known-good compiled file rather than 500-ing the whole page.
Integrate with Vite only at the edges. The sane split is: Sasso compiles app.scss → public/css/app.css, and Vite handles JS + asset hashing. Don't try to make Sasso a Vite plugin; run php artisan css:build as a prebuild npm script instead.
Test the compiled output, not the compiler. A Pest test that compiles a fixture and asserts on normalized CSS catches upgrade regressions (Dart Sass parity gaps change between Sasso releases) far better than asserting the API returns a string.
The 0.1.1 change is the one that will bite you. Moving to composer-binary-downloader means the binary is no longer something you obtain manually — but it also means the plugin is now a hard install-time dependency. Three ways this fails silently-ish:
allow-plugins not set → plugin skipped, no binary, runtime failure.composer install --no-plugins in a hardened deploy pipeline → same result. Drop that flag or run composer install without it for this project.--no-dev is fine, but --no-scripts is not the issue — plugins run separately, verify your actual pipeline rather than assuming.Alpine/musl is the classic trap. Prebuilt Rust binaries for Linux are typically glibc-linked. If your app image is php:8.x-alpine, either build the shared library in a builder stage or use a -bookworm/-slim base. Symptom is a load failure or an immediate segfault, not a helpful message.
Segfaults are not exceptions. FFI misbehaviour — ABI mismatch between the PHP binding and the downloaded library, a corrupt partial download, wrong architecture — takes down the PHP process. Anything running under FPM, Octane, or a queue worker dies with it. This is the single strongest argument for build-time compilation; if you compile at runtime, at minimum isolate it behind a health check.
Version-lock the pair. At 0.1.x the PHP shim and the native library are versioned together and the ABI is not stable. Pin exact (0.1.1, not ^0.1) in composer.json, commit the lock file, and re-verify on every bump — release notes like "adopt a downloader" say nothing about whether the binding changed.
Don't serialize or share the handle. The compiler instance is per-process; a singleton in the container is fine, but never put it on a queued job or into a cache, and don't assume it survives octane:reload cleanly — resolve it lazily.
Check the library-path override before you need it. If your deploy layout puts vendor/ read-only or symlinked (deployer-style current/releases), confirm how the package locates the .so/.dylib and whether an env var can point at it. Test a symlinked release dir explicitly — path resolution bugs surface only there.
Dart Sass parity is aspirational, not guaranteed. Sasso targets compatibility but at 0.1.x you should assume gaps in advanced features: custom importers, certain @use/module-system corners, source maps, and any JS-API-only functionality. If your stylesheets depend on sass custom functions or PostCSS-before-Sass ordering, prototype that specific feature before committing to the migration.
Keep a fallback path in the codebase. A one-method interface (compile(string $path): string) with a Sasso implementation and an scssphp implementation costs ~20 lines and turns "FFI broke on the new base image" from an incident into a config flip. This is the highest-value thing you can do on day one.
Debugging checklist when it just fails: php -m | grep -i ffi → ffi.enable value for the specific SAPI (php-fpm -i, not CLI) → does the library file exist under vendor/ → ldd it for missing symbols → file it for arch/libc → only then suspect the API.
How can I help you explore Laravel packages today?