Docs menu
Customizing the build
How to shape the PHP build on Primcloud: the Composer compile script, PHP extensions, custom web server config, and Debian packages via project.toml.
On this page
Most PHP apps deploy on Primcloud with no custom build steps. The build installs the runtime, runs composer install, and starts a web process. When you need more, three surfaces do the work: Composer's own compile script, extra .ini files and web server configs shipped in the repo, and Debian package installs for OS-level libraries.
Build hooks#
Post-install work goes through the compile script in composer.json. Composer runs it after install finishes, as part of the build phase:
{
"scripts": {
"compile": [
"php artisan config:cache",
"php artisan route:cache",
"php artisan view:cache"
]
}
}
Each entry runs sequentially. A non-zero exit code fails the build. Environment variables set on the environment are available to every command.
Pre-install work, anything that has to run before Composer resolves dependencies, is rare on PHP and doesn't have a first-class hook. The workaround is a script committed to the repo that you invoke from compile after Composer's finished, or (for build-only utilities) a require-dev entry that gets skipped in production but runs whatever it needs when you composer install locally.
JavaScript assets in the build. A PHP app with a package.json at the root (typical for a Laravel or Symfony app that builds front-end assets with Vite) gets a JavaScript toolchain as well. It runs after Composer, compile script included, so vendor/ is in place when your asset build scans it. The lockfile you commit picks the package manager:
| Lockfile | Asset build |
|---|---|
package-lock.json, yarn.lock, or pnpm-lock.yaml |
Node.js is installed and dependencies are installed with npm, Yarn, or pnpm. See Node.js package managers. |
bun.lock or bun.lockb |
Bun is installed, without Node.js, and dependencies are installed with bun install --frozen-lockfile. See Bun package managers. |
Either way, the build script in package.json runs after the install (along with heroku-prebuild and heroku-postbuild, when defined). Put vite build there rather than in scripts.compile, which runs before the JavaScript toolchain is installed. A web line in your Procfile decides the web process. Without a Procfile, a start script in package.json, or a root server.js or index.js (with Bun, also server.ts or index.ts), becomes the web process in place of the PHP web server. Add a Procfile to keep PHP serving.
Custom install steps#
PHP extension configuration. Any file matching .user.ini or a *.ini file placed in the runtime's additional.ini directory is loaded at PHP startup. The idiomatic pattern is to add a .user.ini at the document root:
memory_limit = 512M
upload_max_filesize = 20M
post_max_size = 20M
max_execution_time = 60
For settings that must be applied at PHP startup (not per-request), such as opcache.* tuning or extension loading, commit a file under a .heroku/php/php.ini.d/ directory in your repo. The build picks it up during the compile phase and includes it in the runtime configuration.
Custom web server configuration. Both heroku-php-apache2 and heroku-php-nginx accept a config file path via -C:
web: heroku-php-nginx -C nginx-server.conf public/
The referenced file is included inside the launcher's generated server block: you drop in location blocks, rewrite rules, header manipulation, and so on. Don't write a full nginx.conf from scratch; the launcher's outer config handles listening on $PORT, PHP-FPM wiring, and worker sizing.
The same shape works for Apache:
web: heroku-php-apache2 -C apache-app.conf public/
Native dependencies#
Two shapes of "native" your PHP app can rely on:
PHP extensions with native code. Extensions like ext-redis, ext-imagick, ext-mongodb, and ext-grpc link against system libraries but are distributed as PHP extensions the build knows how to install. Declare them in composer.json:
{
"require": {
"php": "^8.4",
"ext-redis": "*",
"ext-imagick": "*",
"ext-mongodb": "*"
}
}
Run composer update locally to write the requirement into composer.lock, commit, and push. The build resolves each ext-* to the version the Primcloud Runtime ships and installs the extension along with the PHP runtime. No extra setup required; they're loaded automatically at runtime.
The supported extension set includes the standard PHP extensions (built into the runtime) plus common third-party ones: redis, imagick, mongodb, amqp, grpc, memcached, apcu, newrelic, phalcon, and others. Your build log names the exact version it resolves for each extension it installs.
OS-level shared libraries not bundled with an extension: a Debian package your code shells out to, a shared object a native extension depends on. These come from the base image or are declared in project.toml, covered in the next section.
Debian packages#
When your app needs OS-level libraries or CLI tools that aren't in Primcloud's base image, declare them in a project.toml at the repo root. Primcloud's build installs them automatically when it finds the [com.heroku.buildpacks.deb-packages] table.
Declare packages to install:
[_]
schema-version = "0.2"
[com.heroku.buildpacks.deb-packages]
install = [
"ghostscript",
"libreoffice",
"poppler-utils",
]
Each entry is a Debian package name from the Ubuntu 24.04 archive. Packages are installed into their own layer and their binaries added to PATH, shared libraries to LD_LIBRARY_PATH, for both the build and runtime phases.
Advanced options (rarely needed):
- Skip transitive dependencies:
{ name = "some-package", skip_dependencies = true } - Force reinstall:
{ name = "some-package", force = true } - Install from a URL:
download = ["https://example.com/package-1.2.3.deb"] - Add a custom Debian repository: a
[[com.heroku.buildpacks.deb-packages.sources]]table withuri,suites,components,arch, andsigned_by(the source key as an ASCII-armored PGP block).
Note
If you're migrating from Heroku Classic and have an Aptfile at the repo root, the build does not read it. It detects the file and prints a migration warning, then exits without installing anything. Move your package list into project.toml under the schema above.