Skip to main content Skip to navigation menu

Adding custom and contrib packages to Sculpin via Nix

Sculpin is a PHP static site generator built on top of Symfony, which means it can be extended with Symfony bundles and other PHP packages.

Usually that means adding dependencies with Composer, but I wanted to keep this project Nix-first and avoid a project-level composer.json and composer.lock.

There are two kinds of PHP packages I add to Sculpin:

  1. Custom packages that live in this repository.
  2. Contrib packages fetched from somewhere like GitHub.

Custom packages

For project-specific PHP code, I keep a packages/ directory at the root of the repository.

Each package has its own directory with source code, and I register them via packages/autoload.php, which Sculpin prepends on every run:

spl_autoload_register(function (string $class): void {
    $prefixes = [
        'Opdavies\\PresentationCounter\\' => __DIR__ . '/presentation-counter/src',
    ];

    // ...
});

For example, packages/presentation-counter/ provides a Twig extension that counts how many presentations I've given.

Contrib packages

For third-party packages, I create a Nix derivation instead of using Composer.

I recently did this with janbuecker/sculpin-meta-navigation-bundle, which builds a navigation menu from page metadata.

The process is:

  1. Create a Nix derivation that fetches a pinned version of the package.
  2. Expose it as a flake package in nix/packages.nix.
  3. Add it to the site build inputs and set an environment variable pointing to its store path.
  4. Register the package namespace in packages/autoload.php using that environment variable.
  5. If the package is a Symfony bundle, register it in app/SculpinKernel.php.

For the navigation bundle, the derivation looks like this:

{
  fetchFromGitHub,
  stdenv,
}:

stdenv.mkDerivation {
  pname = "sculpin-meta-navigation-bundle";
  version = "unstable-2022-08-26";

  src = fetchFromGitHub {
    owner = "janbuecker";
    repo = "sculpin-meta-navigation-bundle";
    rev = "9a0898dc4506c1ad2167512c0426fd2aae344df3";
    hash = "sha256-hyxmJIFGn9QqSt9KDtd9d9s7h9L5a2AXkbK6ttEgJVc=";
  };

  installPhase = ''
    runHook preInstall
    cp -r . "$out"
    runHook postInstall
  '';
}

Because the store path is exported as SCULPIN_META_NAVIGATION_BUNDLE_PATH, the autoloader can find the classes without touching Sculpin's vendor directory.

One wrapper for dev and production

I also created a single sculpin-with-bundle wrapper package that exports the environment variable and prepends the autoloader.

The dev shell, the start command, and the production build all use the same wrapper, so the bundle is always loaded consistently.

Why Nix?

Using Nix means the package version is pinned with a hash, the build is reproducible, and I don't need to maintain a separate composer.lock. It also keeps the project consistent with how everything else is managed.

I've written up the full process so I can refer back to it when adding the next package.