Docs menu
PHP on Primcloud
How Primcloud detects a PHP app, resolves the runtime, installs Composer dependencies, caches them, and starts the web process under Apache or nginx.
On this page
The build detects a PHP app from composer.json, resolves the runtime and extensions from your platform requirements, installs your Composer dependencies, caches them between builds, and starts a web process: Apache with PHP-FPM by default, or nginx with PHP-FPM when you ask for it in a Procfile. This page walks through each phase and the settings that shape it.
Detection#
Primcloud runs the PHP toolchain when the root of your repo contains a composer.json file. That's the only signal; an empty {} is enough to trigger detection.
The filename is customizable via the COMPOSER environment variable. If you set COMPOSER=app/composer.json, the build looks for that path instead, and derives the lockfile name from it (app/composer.lock). The default is composer.json at the repo root.
If composer.json declares dependencies in require, Primcloud also requires a composer.lock alongside it. Missing lockfiles fail the build with an explicit message pointing at composer update. See Troubleshooting → No Composer lock file found.
Install phase#
The install phase bootstraps three things in order:
- The PHP runtime and requested extensions are resolved from your platform requirements (the
phpandext-*entries incomposer.json/composer.lock) and installed into their own layer. See Runtime versions for how the version is chosen. - The web server binaries. Both Apache 2.4 and nginx are installed in a separate
webserverslayer so theheroku-php-apache2andheroku-php-nginxlaunchers are available at runtime regardless of which one yourProcfilepicks. - Composer itself is installed into its own layer, at the version determined by the
plugin-api-versionyourcomposer.lockwas generated against.
At the end of the install phase, composer, php, and the two web server launchers are all on PATH for the compile phase and for runtime.
Compile phase#
Once the runtime is installed, the build runs:
composer install --no-dev --no-progress --no-interaction --optimize-autoloader --prefer-dist
--no-dev skips require-dev dependencies, so build-only tools like phpunit or phpstan don't ship in the runtime image. --optimize-autoloader generates a classmap so autoloading doesn't stat the filesystem at every request.
If your composer.json defines a compile script under scripts, Composer runs it after install. That's the extension point for custom build work: asset compilation, view caching, migration file generation. See Customizing the build → Build hooks.
The build also appends the Composer bin-dir (vendor/bin by default) to PATH, so any binaries your dependencies install (including the heroku-php-apache2 and heroku-php-nginx launchers from the built-in web servers) resolve without a full path.
Cache behavior#
Primcloud persists Composer's own package cache in a per-app cache image between builds. When your composer.lock hasn't changed, subsequent builds reuse the cached downloads and skip the network round-trip to Packagist and any private repositories.
The PHP runtime, extensions, and web server layers are also cached: if a build asks for the same PHP version and extensions as the previous one, the layer is reused without re-downloading.
The build log's Installing dependencies section makes it easy to see what happened: the first line of each package's install output shows whether it came from cache or was freshly downloaded.
Release phase conventions#
Procfile takes precedence. If your repo has a Procfile at the root, Primcloud uses it verbatim to define process types, the same as every other language on Primcloud. See Pod Formations for the file format.
No Procfile. If there's no Procfile, Primcloud registers a single default process:
web: heroku-php-apache2
This starts Apache 2.4 with PHP-FPM serving from the repo root as the document root. Most real apps override this with an explicit Procfile to point at a public/ or web/ subdirectory:
web: heroku-php-apache2 public/
Choosing nginx over Apache. Swap the launcher name to run nginx with PHP-FPM instead:
web: heroku-php-nginx public/
Both launchers bind PHP-FPM to $PORT, size their worker pool to the pod's available memory, and route requests to index.php by default. Apache and nginx are both fully supported, so pick whichever your app expects. Passing a custom config file (for URL rewrites, header manipulation, or extra location blocks) is covered under Customizing the build → Custom install steps.
Worker and CLI process types. Anything that isn't a web server goes in the Procfile too:
web: heroku-php-nginx public/
worker: php artisan queue:work
Each entry becomes its own process type: one pod per line, scaled independently from the dashboard.
PHP-FPM defaults. The launcher configures PHP-FPM with two production-oriented timeouts:
request_slowlog_timeout = 3s: logs a slow-request warning tostderrwhen a request takes longer than three seconds.request_terminate_timeout = 30s: kills a request that's still running after thirty seconds, freeing the worker.
Both are overridable via custom PHP-FPM configuration passed through the launcher.
Runtime environment. Primcloud sets these variables on every process at runtime:
| Variable | Value | Purpose |
|---|---|---|
PATH |
With PHP, Composer, and vendor/bin prepended |
So php, composer, and installed CLI tools resolve without a full path. |
COMPOSER_MEMORY_LIMIT |
Sized to available pod memory | Prevents Composer OOM during long install runs. |
COMPOSER_NO_INTERACTION |
1 |
Composer never prompts at build time. |
COMPOSER_MIRROR_PATH_REPOS |
1 |
Path repositories are copied, not symlinked. |
COMPOSER_PROCESS_TIMEOUT |
0 |
Composer subprocess timeout disabled. |
PORT |
5000 |
The port your web process binds to. Fixed, and set only on web; worker and other process types never receive a PORT. |
Both launchers read PORT and bind PHP-FPM to it for you, so a standard heroku-php-apache2 or heroku-php-nginx process needs no port handling of its own. A hand-rolled entrypoint has to read $PORT itself.
Anything you set in the Secrets panel is layered on top of these at runtime.