Repository: railwayapp/nixpacks
Pull request #1020 (2023-12-04 02:57:42)

Add support for Java 20/21 in Java & Scala Providers
## What?

This PR adds Java 20 and 21 support in Java and Scala providers.

Specifically for Scala, it also updates the Eclipse Temurin images to the latest
versions.

- [x] Tests are added/updated if needed
- [x] Docs are updated if needed


marcospereira: Duh! I now see #1018 exists. This is slightly more comprehensive than that. A reasonable path would be to merge that PR (after adjusting the tests), and then I can add other changes on top of it.

@aleksrutins, makes sense to you?
aleksrutins: Yeah, that makes sense.
marcospereira: @coffee-cup, sorry for directly pinging you here, but I'm unsure who can approve the workflows to run and add the labels. Can you also take a look at #1021?
aleksrutins: Actually, I don't really think my PR has anything that yours don't, so I think that mine could be closed and yours could be merged.
marcospereira: > Actually, I don't really think my PR has anything that yours don't, so I think that mine could be closed and yours could be merged.

Makes sense to me too. Do you have permission to approve the workflows here, though?
coffee-cup: The tests are failing because we need to update the Nix archive version being used for Java. As an example you can look at 
- https://github.com/railwayapp/nixpacks/blob/main/src/providers/java.rs#L18
- https://github.com/railwayapp/nixpacks/blob/main/src/providers/java.rs#L41

It is failing when building

```
cargo run -- build examples/java-gradle-8 --name gradle
```

You can set the archive version to something like

```
59dc10b5a6f2a592af36375c68fda41246794b86
```

I was experimenting with this before I noticed that this PR existed

![image](https://github.com/railwayapp/nixpacks/assets/3044853/e52807bb-c51c-4dbe-8575-62d3fbb55903)


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 65e5d26..1359e66 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1044,7 +1044,7 @@ dependencies = [
 
 [[package]]
 name = "nixpacks"
-version = "1.19.1"
+version = "1.20.0"
 dependencies = [
  "actix-web",
  "anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index effba9b..7eba566 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "nixpacks"
-version = "1.19.1"
+version = "1.20.0"
 edition = "2021"
 license = "MIT"
 authors = ["Railway <contact@railway.app>"]
diff --git a/docs/pages/docs/providers/go.md b/docs/pages/docs/providers/go.md
index 04800aa..e5ae847 100644
--- a/docs/pages/docs/providers/go.md
+++ b/docs/pages/docs/providers/go.md
@@ -12,10 +12,12 @@ Go is detected if a `main.go` file is found.
 
 ## Setup
 
-The following Go version are available
+The following Go versions are available:
 
-- `1.17` (Default)
 - `1.18`
+- `1.19`
+- `1.20`
+- `1.21` (default)
 
 The version is parsed from the `go.mod` file.
 
diff --git a/docs/pages/docs/providers/java.md b/docs/pages/docs/providers/java.md
index b29dbc3..4322536 100644
--- a/docs/pages/docs/providers/java.md
+++ b/docs/pages/docs/providers/java.md
@@ -12,6 +12,8 @@ Java is detected if a `pom.[xml|atom|clj|groovy|rb|scala|yaml|yml]` or `gradlew`
 
 The following major JDK versions are available
 
+- `21`
+- `20`
 - `19`
 - `17` (Default)
 - `11`
diff --git a/docs/pages/docs/providers/scala.md b/docs/pages/docs/providers/scala.md
index 663cb6f..c301eb6 100644
--- a/docs/pages/docs/providers/scala.md
+++ b/docs/pages/docs/providers/scala.md
@@ -13,6 +13,8 @@ at Java. Scala is detected by `build.sbt` in project root.
 
 The following major JDK versions are available
 
+- `21`
+- `20`
 - `19`
 - `17` (Default)
 - `11`
diff --git a/flake.nix b/flake.nix
index f007acd..b09c4ab 100644
--- a/flake.nix
+++ b/flake.nix
@@ -20,7 +20,7 @@
         let
           package = with nixpkgs; rustPlatform.buildRustPackage {
             pname = "nixpacks";
-            version = "1.19.1";
+            version = "1.20.0";
             src = ./.;
             cargoLock = {
               lockFile = ./Cargo.lock;
diff --git a/src/providers/go.rs b/src/providers/go.rs
index 28ed3ae..5b3d6c3 100644
--- a/src/providers/go.rs
+++ b/src/providers/go.rs
@@ -13,8 +13,26 @@ use anyhow::Result;
 pub struct GolangProvider {}
 
 const BINARY_NAME: &str = "out";
-const AVAILABLE_GO_VERSIONS: &[(&str, &str)] = &[("1.17", "go"), ("1.18", "go_1_18")];
+const AVAILABLE_GO_VERSIONS: &[(&str, &str, &str)] = &[
+    (
+        "1.18",
+        "go_1_18",
+        "5148520bfab61f99fd25fb9ff7bfbb50dad3c9db",
+    ),
+    (
+        "1.19",
+        "go_1_19",
+        "5148520bfab61f99fd25fb9ff7bfbb50dad3c9db",
+    ),
+    (
+        "1.20",
+        "go_1_20",
+        "1f13eabcd6f5b00fe9de9575ac52c66a0e887ce6",
+    ),
+    ("1.21", "go", "1f13eabcd6f5b00fe9de9575ac52c66a0e887ce6"),
+];
 const DEFAULT_GO_PKG_NAME: &str = "go";
+const DEFAULT_ARCHIVE: &str = "1f13eabcd6f5b00fe9de9575ac52c66a0e887ce6";
 
 const GO_BUILD_CACHE_DIR: &str = "/root/.cache/go-build";
 
@@ -31,8 +49,12 @@ impl Provider for GolangProvider {
         let mut plan = BuildPlan::default();
 
         let go_mod = self.read_go_mod_if_exists(app)?;
-        let nix_pkg = GolangProvider::get_nix_golang_pkg(go_mod.as_ref())?;
-        plan.add_phase(Phase::setup(Some(vec![Pkg::new(&nix_pkg)])));
+        let (nix_pkg, archive) = GolangProvider::get_nix_golang_pkg(go_mod.as_ref())?;
+
+        let mut setup = Phase::setup(Some(vec![Pkg::new(&nix_pkg)]));
+        setup.set_nix_archive(archive);
+
+        plan.add_phase(setup);
 
         if app.includes_file("go.mod") {
             let mut install = Phase::install(Some("go mod download".to_string()));
@@ -82,7 +104,7 @@ impl GolangProvider {
         }
     }
 
-    pub fn get_nix_golang_pkg(go_mod_contents: Option<&String>) -> Result<String> {
+    pub fn get_nix_golang_pkg(go_mod_contents: Option<&String>) -> Result<(String, String)> {
         if go_mod_contents.is_some() {
             let mut lines = go_mod_contents.as_ref().unwrap().lines();
             let go_version_line = lines.find(|line| line.trim().starts_with("go"));
@@ -90,20 +112,27 @@ impl GolangProvider {
             if let Some(go_version_line) = go_version_line {
                 let go_version = go_version_line.split_whitespace().nth(1).unwrap();
 
-                if let Some(nix_pkg) = version_number_to_pkg(go_version) {
-                    return Ok(nix_pkg);
-                }
+                let nix_pkg = version_number_to_pkg(go_version)
+                    .unwrap_or_else(|| DEFAULT_GO_PKG_NAME.to_string());
+                let nix_archive = version_number_to_archive(go_version)
+                    .unwrap_or_else(|| DEFAULT_ARCHIVE.to_string());
+
+                return Ok((nix_pkg, nix_archive));
             }
         }
 
-        Ok(DEFAULT_GO_PKG_NAME.to_string())
+        Ok((DEFAULT_GO_PKG_NAME.to_string(), DEFAULT_ARCHIVE.to_string()))
     }
 }
 
 fn version_number_to_pkg(version: &str) -> Option<String> {
-    let matched_version = AVAILABLE_GO_VERSIONS.iter().find(|(v, _)| v == &version);
+    let matched_version = AVAILABLE_GO_VERSIONS.iter().find(|(v, _, _)| v == &version);
+    matched_version.map(|(_, pkg, _)| (*pkg).to_string())
+}
 
-    matched_version.map(|(_, pkg)| (*pkg).to_string())
+fn version_number_to_archive(version: &str) -> Option<String> {
+    let matched_version = AVAILABLE_GO_VERSIONS.iter().find(|(v, _, _)| v == &version);
+    matched_version.map(|(_, _, archive)| (*archive).to_string())
 }
 
 #[cfg(test)]
@@ -113,7 +142,7 @@ mod test {
     #[test]
     fn test_no_go_mod() -> Result<()> {
         assert_eq!(
-            GolangProvider::get_nix_golang_pkg(None)?,
+            GolangProvider::get_nix_golang_pkg(None)?.0,
             DEFAULT_GO_PKG_NAME.to_string()
         );
 
@@ -127,7 +156,7 @@ mod test {
         ";
 
         assert_eq!(
-            GolangProvider::get_nix_golang_pkg(Some(&go_mod_contents.to_string()))?,
+            GolangProvider::get_nix_golang_pkg(Some(&go_mod_contents.to_string()))?.0,
             "go_1_18".to_string()
         );
 
@@ -141,7 +170,7 @@ mod test {
         ";
 
         assert_eq!(
-            GolangProvider::get_nix_golang_pkg(Some(&go_mod_contents.to_string()))?,
+            GolangProvider::get_nix_golang_pkg(Some(&go_mod_contents.to_string()))?.0,
             DEFAULT_GO_PKG_NAME.to_string()
         );
 
diff --git a/src/providers/java.rs b/src/providers/java.rs
index 2a2857f..93324cf 100644
--- a/src/providers/java.rs
+++ b/src/providers/java.rs
@@ -15,7 +15,7 @@ pub struct JavaProvider {}
 
 const DEFAULT_JDK_VERSION: u32 = 17;
 const DEFAULT_GRADLE_VERSION: u32 = 7;
-const GRADLE_NIXPKGS_ARCHIVE: &str = "2f9286912cb215969ece465147badf6d07aa43fe";
+const JAVA_NIXPKGS_ARCHIVE: &str = "59dc10b5a6f2a592af36375c68fda41246794b86";
 
 impl Provider for JavaProvider {
     fn name(&self) -> &str {
@@ -38,7 +38,7 @@ impl Provider for JavaProvider {
         let (setup, build) = if self.is_using_gradle(app) {
             let pkgs = self.get_jdk_and_gradle_pkgs(app, env)?;
             let mut setup = Phase::setup(Some(pkgs));
-            setup.set_nix_archive(GRADLE_NIXPKGS_ARCHIVE.to_string());
+            setup.set_nix_archive(JAVA_NIXPKGS_ARCHIVE.to_string());
 
             let mut build = Phase::build(None);
             let gradle_exe = self.get_gradle_exe(app);
@@ -57,7 +57,8 @@ impl Provider for JavaProvider {
             let jdk_version = self.get_jdk_version(app, env)?;
             let jdk_pkg = self.get_jdk_pkg(jdk_version)?;
 
-            let setup = Phase::setup(Some(vec![jdk_pkg, Pkg::new("maven")]));
+            let mut setup = Phase::setup(Some(vec![jdk_pkg, Pkg::new("maven")]));
+            setup.set_nix_archive(JAVA_NIXPKGS_ARCHIVE.to_string());
 
             let mvn_exe = self.get_maven_exe(app);
             let mut build = Phase::build(Some(format!("{mvn_exe} -DoutputFile=target/mvn-dependency-list.log -B -DskipTests clean dependency:list install"
@@ -169,6 +170,8 @@ impl JavaProvider {
 
     fn get_jdk_pkg(&self, jdk_version: u32) -> Result<Pkg> {
         let pkg = match jdk_version {
+            21 => Pkg::new("jdk21"),
+            20 => Pkg::new("jdk20"),
             19 => Pkg::new("jdk"),
             17 => Pkg::new("jdk17"),
             11 => Pkg::new("jdk11"),
@@ -355,6 +358,42 @@ mod tests {
             .unwrap()
         );
 
+        assert_eq!(
+            Pkg::new("jdk21"),
+            java.get_jdk_pkg(
+                java.get_jdk_version(
+                    &App::new("examples/java-gradle-hello-world").unwrap(),
+                    &Environment::from_envs(vec!["NIXPACKS_JDK_VERSION=21"]).unwrap(),
+                )
+                .unwrap()
+            )
+            .unwrap()
+        );
+
+        assert_eq!(
+            Pkg::new("jdk20"),
+            java.get_jdk_pkg(
+                java.get_jdk_version(
+                    &App::new("examples/java-gradle-hello-world").unwrap(),
+                    &Environment::from_envs(vec!["NIXPACKS_JDK_VERSION=20"]).unwrap(),
+                )
+                .unwrap()
+            )
+            .unwrap()
+        );
+
+        assert_eq!(
+            Pkg::new("jdk"),
+            java.get_jdk_pkg(
+                java.get_jdk_version(
+                    &App::new("examples/java-gradle-hello-world").unwrap(),
+                    &Environment::from_envs(vec!["NIXPACKS_JDK_VERSION=19"]).unwrap(),
+                )
+                .unwrap()
+            )
+            .unwrap()
+        );
+
         assert_eq!(
             Pkg::new("jdk11"),
             java.get_jdk_pkg(
diff --git a/src/providers/php/scripts/prestart.mjs b/src/providers/php/scripts/prestart.mjs
index 9079982..a957f9f 100755
--- a/src/providers/php/scripts/prestart.mjs
+++ b/src/providers/php/scripts/prestart.mjs
@@ -13,11 +13,8 @@ if (process.argv.length != 4) {
     process.exit(1);
 }
 
-if (isLaravel()) {
-   checkEnvErrors('/app')
-}
-
 await Promise.all([
+    isLaravel() ? checkEnvErrors('/app') : Promise.resolve(),
     access('/app/storage', constants.R_OK)
         .then(() => e('chmod -R ugo+rw /app/storage'))
         .catch(() => {}),
diff --git a/src/providers/scala.rs b/src/providers/scala.rs
index 3b4db8f..bf07d18 100644
--- a/src/providers/scala.rs
+++ b/src/providers/scala.rs
@@ -78,6 +78,8 @@ impl ScalaProvider {
 
     fn get_jdk_pkg_name(&self, jdk_version: u32) -> &str {
         match jdk_version {
+            21 => "jdk21",
+            20 => "jdk20",
             19 => "jdk",
             11 => "jdk11",
             8 => "jdk8",
@@ -89,12 +91,14 @@ impl ScalaProvider {
 
     fn get_jdk_run_image(&self, jdk_version: u32) -> &str {
         match jdk_version {
+            21 => "eclipse-temurim:21.0.1_12-jre-jammy",
+            20 => "eclipse-temurim:20.0.2_9-jre-jammy",
             19 => "eclipse-temurin:19.0.2_7-jre-jammy",
-            11 => "eclipse-temurin:11.0.18_10-jre-jammy",
-            8 => "eclipse-temurin:8u362-b09-jre-jammy",
+            11 => "eclipse-temurin:11.0.21_9-jre-jammy",
+            8 => "eclipse-temurin:8u392-b08-jre-jammy",
 
             // Using 17 as default because its the latest LTS
-            _ => "eclipse-temurin:17.0.5_8-jre-jammy",
+            _ => "eclipse-temurin:17.0.9_9-jre-jammy",
         }
     }
 
@@ -123,6 +127,34 @@ impl ScalaProvider {
 mod tests {
     use super::*;
 
+    #[test]
+    fn test_get_jdk_pkg_name() {
+        let scala = ScalaProvider {};
+
+        // defaults to Java 17
+        assert_eq!(
+            "jdk17",
+            scala
+                .get_jdk_pkg_name(scala.get_jdk_version(&Environment::from_envs(vec![]).unwrap(),))
+        );
+
+        // Supports Java 20
+        assert_eq!(
+            "jdk20",
+            scala.get_jdk_pkg_name(scala.get_jdk_version(
+                &Environment::from_envs(vec!["NIXPACKS_JDK_VERSION=20"]).unwrap(),
+            ))
+        );
+
+        // Supports Java 21
+        assert_eq!(
+            "jdk21",
+            scala.get_jdk_pkg_name(scala.get_jdk_version(
+                &Environment::from_envs(vec!["NIXPACKS_JDK_VERSION=21"]).unwrap(),
+            ))
+        );
+    }
+
     #[test]
     fn test_sbt_package() {
         let scala = ScalaProvider {};


Test patch:
diff --git a/tests/snapshots/generate_plan_tests__php_api.snap b/tests/snapshots/generate_plan_tests__php_api.snap
index a464b35..f6f56a0 100644
--- a/tests/snapshots/generate_plan_tests__php_api.snap
+++ b/tests/snapshots/generate_plan_tests__php_api.snap
@@ -15,7 +15,7 @@ expression: plan
     "nginx.template.conf": "worker_processes 5;\ndaemon off;\n\nworker_rlimit_nofile 8192;\n\nevents {\n  worker_connections  4096;  # Default: 1024\n}\n\nhttp {\n    include    $!{nginx}/conf/mime.types;\n    index    index.html index.htm index.php;\n\n    default_type application/octet-stream;\n    log_format   main '$remote_addr - $remote_user [$time_local]  $status '\n        '\"$request\" $body_bytes_sent \"$http_referer\" '\n        '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n    access_log /dev/stdout;\n    error_log /dev/stdout;\n    sendfile     on;\n    tcp_nopush   on;\n    server_names_hash_bucket_size 128; # this seems to be required for some vhosts\n\n    server {\n        listen ${PORT};\n        listen [::]:${PORT};\n        server_name localhost;\n\n        $if(NIXPACKS_PHP_ROOT_DIR) (\n            root ${NIXPACKS_PHP_ROOT_DIR};\n        ) else (\n            root /app;\n        )\n     \n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-Content-Type-Options \"nosniff\";\n     \n        index index.php;\n     \n        charset utf-8;\n     \n        $if(IS_LARAVEL) (\n            location / {\n                try_files $uri $uri/ /index.php?$query_string;\n            }\n        ) else ()\n        \n        $if(NIXPACKS_PHP_FALLBACK_PATH) (\n          location / {\n            try_files $uri $uri/ ${NIXPACKS_PHP_FALLBACK_PATH}?$query_string;\n          }\n        ) else ()\n     \n        location = /favicon.ico { access_log off; log_not_found off; }\n        location = /robots.txt  { access_log off; log_not_found off; }\n     \n        $if(IS_LARAVEL) (\n            error_page 404 /index.php;\n        ) else ()\n     \n        location ~ \\.php$ {\n            fastcgi_pass 127.0.0.1:9000;\n            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n            include $!{nginx}/conf/fastcgi_params;\n            include $!{nginx}/conf/fastcgi.conf;\n        }\n     \n        location ~ /\\.(?!well-known).* {\n            deny all;\n        }\n    }\n}",
     "php-fpm.conf": "[www]\nlisten = 127.0.0.1:9000\nuser = nobody\npm = dynamic\npm.max_children = 50\npm.min_spare_servers = 4\npm.max_spare_servers = 32\npm.start_servers = 18\nclear_env = no\n",
     "scripts/config/template.mjs": "import { readFile, writeFile } from \"fs/promises\";\nimport { getNixPath } from \"../util/nix.mjs\";\n\nconst replaceStr = input =>\n    input\n        // If statements\n        .replaceAll(/\\$if\\s*\\((\\w+)\\)\\s*\\(([^]*?)\\)\\s*else\\s*\\(([^]*?)\\)/gm,\n            (_all, condition, value, otherwise) =>\n                process.env[condition] ? replaceStr(value) : replaceStr(otherwise)\n        )\n        // Variables\n        .replaceAll(/\\${(\\w+)}/g,\n            (_all, name) => process.env[name]\n        )\n        // Nix paths\n        .replaceAll(/\\$!{(\\w+)}/g,\n            (_all, exe) => getNixPath(exe)\n        )\n\nexport async function compileTemplate(infile, outfile) {\n    await writeFile(outfile,\n        replaceStr(await readFile(infile, { encoding: 'utf8' })),\n        { encoding: 'utf8' })\n}\n",
-    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nif (isLaravel()) {\n   checkEnvErrors('/app')\n}\n\nawait Promise.all([\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
+    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nawait Promise.all([\n    isLaravel() ? checkEnvErrors('/app') : Promise.resolve(),\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
     "scripts/util/cmd.mjs": "import { execSync } from \"child_process\";\n\nexport const e = cmd => execSync(cmd).toString().replace('\\n', '');",
     "scripts/util/laravel.mjs": "import Logger from \"./logger.mjs\"\nimport * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\n\nconst variableHints = {\n    'APP_ENV': 'You should probably set this to `production`.'\n};\n\nconst logger = new Logger('laravel');\n\nexport const isLaravel = () => process.env['IS_LARAVEL'] != null;\n\nfunction checkVariable(name) {\n    if (!process.env[name]) {\n        let hint =\n            `Your app configuration references the ${name} environment variable, but it is not set.`\n            + (variableHints[name] ?? '');\n\n        logger.warn(hint);\n    }\n}\n\nexport async function checkEnvErrors(srcdir) {\n    const envRegex = /env\\([\"']([^,]*)[\"']\\)/g;\n    const configDir = path.join(srcdir, 'config');\n\n    const config =\n        (await Promise.all(\n            (await fs.readdir(configDir))\n                .filter(fileName => fileName.endsWith('.php'))\n                .map(fileName => fs.readFile(path.join(configDir, fileName)))\n        )).join('');\n\n    for (const match of config.matchAll(envRegex)) {\n        if (match[1] != 'APP_KEY') checkVariable(match[1]);\n    }\n\n    if (!process.env.APP_KEY) {\n        logger.warn('Your app key is not set! Please set a random 32-character string in your APP_KEY environment variable. This can be easily generated with `openssl rand -hex 16`.');\n    }\n}\n",
     "scripts/util/logger.mjs": "export default class Logger {\n    /** @type string */\n    #tag;\n\n    /**\n    * @param {string} tag\n    */\n    constructor(tag) {\n        this.#tag = tag\n    }\n\n    #log(color, messageType, message, fn = console.log) {\n        fn(`\\x1b[${color}m[${this.#tag}:${messageType}]\\x1b[0m ${message}`)\n    }\n\n    info(message) {\n        this.#log(34, 'info', message)\n    }\n\n    warn(message) {\n        this.#log(35, 'warn', message, console.warn)\n    }\n\n    error(message) {\n        this.#log(31, 'error', message, console.error)\n    }\n}\n",
diff --git a/tests/snapshots/generate_plan_tests__php_custom_config.snap b/tests/snapshots/generate_plan_tests__php_custom_config.snap
index e29909a..112121e 100644
--- a/tests/snapshots/generate_plan_tests__php_custom_config.snap
+++ b/tests/snapshots/generate_plan_tests__php_custom_config.snap
@@ -13,7 +13,7 @@ expression: plan
     "nginx.template.conf": "worker_processes 5;\ndaemon off;\n\nworker_rlimit_nofile 8192;\n\nevents {\n  worker_connections  4096;  # Default: 1024\n}\n\nhttp {\n    include    $!{nginx}/conf/mime.types;\n    index    index.html index.htm index.php;\n\n    default_type application/octet-stream;\n    log_format   main '$remote_addr - $remote_user [$time_local]  $status '\n        '\"$request\" $body_bytes_sent \"$http_referer\" '\n        '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n    access_log /dev/stdout;\n    error_log /dev/stdout;\n    sendfile     on;\n    tcp_nopush   on;\n    server_names_hash_bucket_size 128; # this seems to be required for some vhosts\n\n    server {\n        listen ${PORT};\n        listen [::]:${PORT};\n        server_name localhost;\n\n        $if(NIXPACKS_PHP_ROOT_DIR) (\n            root ${NIXPACKS_PHP_ROOT_DIR};\n        ) else (\n            root /app;\n        )\n     \n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-Content-Type-Options \"nosniff\";\n     \n        index index.php;\n     \n        charset utf-8;\n     \n        $if(IS_LARAVEL) (\n            location / {\n                try_files $uri $uri/ /index.php?$query_string;\n            }\n        ) else ()\n        \n        $if(NIXPACKS_PHP_FALLBACK_PATH) (\n          location / {\n            try_files $uri $uri/ ${NIXPACKS_PHP_FALLBACK_PATH}?$query_string;\n          }\n        ) else ()\n     \n        location = /favicon.ico { access_log off; log_not_found off; }\n        location = /robots.txt  { access_log off; log_not_found off; }\n     \n        $if(IS_LARAVEL) (\n            error_page 404 /index.php;\n        ) else ()\n     \n        location ~ \\.php$ {\n            fastcgi_pass 127.0.0.1:9000;\n            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n            include $!{nginx}/conf/fastcgi_params;\n            include $!{nginx}/conf/fastcgi.conf;\n        }\n     \n        location ~ /\\.(?!well-known).* {\n            deny all;\n        }\n    }\n}",
     "php-fpm.conf": "[www]\nlisten = 127.0.0.1:9000\nuser = nobody\npm = dynamic\npm.max_children = 50\npm.min_spare_servers = 4\npm.max_spare_servers = 32\npm.start_servers = 18\nclear_env = no\n",
     "scripts/config/template.mjs": "import { readFile, writeFile } from \"fs/promises\";\nimport { getNixPath } from \"../util/nix.mjs\";\n\nconst replaceStr = input =>\n    input\n        // If statements\n        .replaceAll(/\\$if\\s*\\((\\w+)\\)\\s*\\(([^]*?)\\)\\s*else\\s*\\(([^]*?)\\)/gm,\n            (_all, condition, value, otherwise) =>\n                process.env[condition] ? replaceStr(value) : replaceStr(otherwise)\n        )\n        // Variables\n        .replaceAll(/\\${(\\w+)}/g,\n            (_all, name) => process.env[name]\n        )\n        // Nix paths\n        .replaceAll(/\\$!{(\\w+)}/g,\n            (_all, exe) => getNixPath(exe)\n        )\n\nexport async function compileTemplate(infile, outfile) {\n    await writeFile(outfile,\n        replaceStr(await readFile(infile, { encoding: 'utf8' })),\n        { encoding: 'utf8' })\n}\n",
-    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nif (isLaravel()) {\n   checkEnvErrors('/app')\n}\n\nawait Promise.all([\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
+    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nawait Promise.all([\n    isLaravel() ? checkEnvErrors('/app') : Promise.resolve(),\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
     "scripts/util/cmd.mjs": "import { execSync } from \"child_process\";\n\nexport const e = cmd => execSync(cmd).toString().replace('\\n', '');",
     "scripts/util/laravel.mjs": "import Logger from \"./logger.mjs\"\nimport * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\n\nconst variableHints = {\n    'APP_ENV': 'You should probably set this to `production`.'\n};\n\nconst logger = new Logger('laravel');\n\nexport const isLaravel = () => process.env['IS_LARAVEL'] != null;\n\nfunction checkVariable(name) {\n    if (!process.env[name]) {\n        let hint =\n            `Your app configuration references the ${name} environment variable, but it is not set.`\n            + (variableHints[name] ?? '');\n\n        logger.warn(hint);\n    }\n}\n\nexport async function checkEnvErrors(srcdir) {\n    const envRegex = /env\\([\"']([^,]*)[\"']\\)/g;\n    const configDir = path.join(srcdir, 'config');\n\n    const config =\n        (await Promise.all(\n            (await fs.readdir(configDir))\n                .filter(fileName => fileName.endsWith('.php'))\n                .map(fileName => fs.readFile(path.join(configDir, fileName)))\n        )).join('');\n\n    for (const match of config.matchAll(envRegex)) {\n        if (match[1] != 'APP_KEY') checkVariable(match[1]);\n    }\n\n    if (!process.env.APP_KEY) {\n        logger.warn('Your app key is not set! Please set a random 32-character string in your APP_KEY environment variable. This can be easily generated with `openssl rand -hex 16`.');\n    }\n}\n",
     "scripts/util/logger.mjs": "export default class Logger {\n    /** @type string */\n    #tag;\n\n    /**\n    * @param {string} tag\n    */\n    constructor(tag) {\n        this.#tag = tag\n    }\n\n    #log(color, messageType, message, fn = console.log) {\n        fn(`\\x1b[${color}m[${this.#tag}:${messageType}]\\x1b[0m ${message}`)\n    }\n\n    info(message) {\n        this.#log(34, 'info', message)\n    }\n\n    warn(message) {\n        this.#log(35, 'warn', message, console.warn)\n    }\n\n    error(message) {\n        this.#log(31, 'error', message, console.error)\n    }\n}\n",
diff --git a/tests/snapshots/generate_plan_tests__php_laravel.snap b/tests/snapshots/generate_plan_tests__php_laravel.snap
index c3f96d6..7071e0f 100644
--- a/tests/snapshots/generate_plan_tests__php_laravel.snap
+++ b/tests/snapshots/generate_plan_tests__php_laravel.snap
@@ -15,7 +15,7 @@ expression: plan
     "nginx.template.conf": "worker_processes 5;\ndaemon off;\n\nworker_rlimit_nofile 8192;\n\nevents {\n  worker_connections  4096;  # Default: 1024\n}\n\nhttp {\n    include    $!{nginx}/conf/mime.types;\n    index    index.html index.htm index.php;\n\n    default_type application/octet-stream;\n    log_format   main '$remote_addr - $remote_user [$time_local]  $status '\n        '\"$request\" $body_bytes_sent \"$http_referer\" '\n        '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n    access_log /dev/stdout;\n    error_log /dev/stdout;\n    sendfile     on;\n    tcp_nopush   on;\n    server_names_hash_bucket_size 128; # this seems to be required for some vhosts\n\n    server {\n        listen ${PORT};\n        listen [::]:${PORT};\n        server_name localhost;\n\n        $if(NIXPACKS_PHP_ROOT_DIR) (\n            root ${NIXPACKS_PHP_ROOT_DIR};\n        ) else (\n            root /app;\n        )\n     \n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-Content-Type-Options \"nosniff\";\n     \n        index index.php;\n     \n        charset utf-8;\n     \n        $if(IS_LARAVEL) (\n            location / {\n                try_files $uri $uri/ /index.php?$query_string;\n            }\n        ) else ()\n        \n        $if(NIXPACKS_PHP_FALLBACK_PATH) (\n          location / {\n            try_files $uri $uri/ ${NIXPACKS_PHP_FALLBACK_PATH}?$query_string;\n          }\n        ) else ()\n     \n        location = /favicon.ico { access_log off; log_not_found off; }\n        location = /robots.txt  { access_log off; log_not_found off; }\n     \n        $if(IS_LARAVEL) (\n            error_page 404 /index.php;\n        ) else ()\n     \n        location ~ \\.php$ {\n            fastcgi_pass 127.0.0.1:9000;\n            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n            include $!{nginx}/conf/fastcgi_params;\n            include $!{nginx}/conf/fastcgi.conf;\n        }\n     \n        location ~ /\\.(?!well-known).* {\n            deny all;\n        }\n    }\n}",
     "php-fpm.conf": "[www]\nlisten = 127.0.0.1:9000\nuser = nobody\npm = dynamic\npm.max_children = 50\npm.min_spare_servers = 4\npm.max_spare_servers = 32\npm.start_servers = 18\nclear_env = no\n",
     "scripts/config/template.mjs": "import { readFile, writeFile } from \"fs/promises\";\nimport { getNixPath } from \"../util/nix.mjs\";\n\nconst replaceStr = input =>\n    input\n        // If statements\n        .replaceAll(/\\$if\\s*\\((\\w+)\\)\\s*\\(([^]*?)\\)\\s*else\\s*\\(([^]*?)\\)/gm,\n            (_all, condition, value, otherwise) =>\n                process.env[condition] ? replaceStr(value) : replaceStr(otherwise)\n        )\n        // Variables\n        .replaceAll(/\\${(\\w+)}/g,\n            (_all, name) => process.env[name]\n        )\n        // Nix paths\n        .replaceAll(/\\$!{(\\w+)}/g,\n            (_all, exe) => getNixPath(exe)\n        )\n\nexport async function compileTemplate(infile, outfile) {\n    await writeFile(outfile,\n        replaceStr(await readFile(infile, { encoding: 'utf8' })),\n        { encoding: 'utf8' })\n}\n",
-    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nif (isLaravel()) {\n   checkEnvErrors('/app')\n}\n\nawait Promise.all([\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
+    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nawait Promise.all([\n    isLaravel() ? checkEnvErrors('/app') : Promise.resolve(),\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
     "scripts/util/cmd.mjs": "import { execSync } from \"child_process\";\n\nexport const e = cmd => execSync(cmd).toString().replace('\\n', '');",
     "scripts/util/laravel.mjs": "import Logger from \"./logger.mjs\"\nimport * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\n\nconst variableHints = {\n    'APP_ENV': 'You should probably set this to `production`.'\n};\n\nconst logger = new Logger('laravel');\n\nexport const isLaravel = () => process.env['IS_LARAVEL'] != null;\n\nfunction checkVariable(name) {\n    if (!process.env[name]) {\n        let hint =\n            `Your app configuration references the ${name} environment variable, but it is not set.`\n            + (variableHints[name] ?? '');\n\n        logger.warn(hint);\n    }\n}\n\nexport async function checkEnvErrors(srcdir) {\n    const envRegex = /env\\([\"']([^,]*)[\"']\\)/g;\n    const configDir = path.join(srcdir, 'config');\n\n    const config =\n        (await Promise.all(\n            (await fs.readdir(configDir))\n                .filter(fileName => fileName.endsWith('.php'))\n                .map(fileName => fs.readFile(path.join(configDir, fileName)))\n        )).join('');\n\n    for (const match of config.matchAll(envRegex)) {\n        if (match[1] != 'APP_KEY') checkVariable(match[1]);\n    }\n\n    if (!process.env.APP_KEY) {\n        logger.warn('Your app key is not set! Please set a random 32-character string in your APP_KEY environment variable. This can be easily generated with `openssl rand -hex 16`.');\n    }\n}\n",
     "scripts/util/logger.mjs": "export default class Logger {\n    /** @type string */\n    #tag;\n\n    /**\n    * @param {string} tag\n    */\n    constructor(tag) {\n        this.#tag = tag\n    }\n\n    #log(color, messageType, message, fn = console.log) {\n        fn(`\\x1b[${color}m[${this.#tag}:${messageType}]\\x1b[0m ${message}`)\n    }\n\n    info(message) {\n        this.#log(34, 'info', message)\n    }\n\n    warn(message) {\n        this.#log(35, 'warn', message, console.warn)\n    }\n\n    error(message) {\n        this.#log(31, 'error', message, console.error)\n    }\n}\n",
diff --git a/tests/snapshots/generate_plan_tests__php_laravel_ext_json.snap b/tests/snapshots/generate_plan_tests__php_laravel_ext_json.snap
index c3f96d6..7071e0f 100644
--- a/tests/snapshots/generate_plan_tests__php_laravel_ext_json.snap
+++ b/tests/snapshots/generate_plan_tests__php_laravel_ext_json.snap
@@ -15,7 +15,7 @@ expression: plan
     "nginx.template.conf": "worker_processes 5;\ndaemon off;\n\nworker_rlimit_nofile 8192;\n\nevents {\n  worker_connections  4096;  # Default: 1024\n}\n\nhttp {\n    include    $!{nginx}/conf/mime.types;\n    index    index.html index.htm index.php;\n\n    default_type application/octet-stream;\n    log_format   main '$remote_addr - $remote_user [$time_local]  $status '\n        '\"$request\" $body_bytes_sent \"$http_referer\" '\n        '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n    access_log /dev/stdout;\n    error_log /dev/stdout;\n    sendfile     on;\n    tcp_nopush   on;\n    server_names_hash_bucket_size 128; # this seems to be required for some vhosts\n\n    server {\n        listen ${PORT};\n        listen [::]:${PORT};\n        server_name localhost;\n\n        $if(NIXPACKS_PHP_ROOT_DIR) (\n            root ${NIXPACKS_PHP_ROOT_DIR};\n        ) else (\n            root /app;\n        )\n     \n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-Content-Type-Options \"nosniff\";\n     \n        index index.php;\n     \n        charset utf-8;\n     \n        $if(IS_LARAVEL) (\n            location / {\n                try_files $uri $uri/ /index.php?$query_string;\n            }\n        ) else ()\n        \n        $if(NIXPACKS_PHP_FALLBACK_PATH) (\n          location / {\n            try_files $uri $uri/ ${NIXPACKS_PHP_FALLBACK_PATH}?$query_string;\n          }\n        ) else ()\n     \n        location = /favicon.ico { access_log off; log_not_found off; }\n        location = /robots.txt  { access_log off; log_not_found off; }\n     \n        $if(IS_LARAVEL) (\n            error_page 404 /index.php;\n        ) else ()\n     \n        location ~ \\.php$ {\n            fastcgi_pass 127.0.0.1:9000;\n            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n            include $!{nginx}/conf/fastcgi_params;\n            include $!{nginx}/conf/fastcgi.conf;\n        }\n     \n        location ~ /\\.(?!well-known).* {\n            deny all;\n        }\n    }\n}",
     "php-fpm.conf": "[www]\nlisten = 127.0.0.1:9000\nuser = nobody\npm = dynamic\npm.max_children = 50\npm.min_spare_servers = 4\npm.max_spare_servers = 32\npm.start_servers = 18\nclear_env = no\n",
     "scripts/config/template.mjs": "import { readFile, writeFile } from \"fs/promises\";\nimport { getNixPath } from \"../util/nix.mjs\";\n\nconst replaceStr = input =>\n    input\n        // If statements\n        .replaceAll(/\\$if\\s*\\((\\w+)\\)\\s*\\(([^]*?)\\)\\s*else\\s*\\(([^]*?)\\)/gm,\n            (_all, condition, value, otherwise) =>\n                process.env[condition] ? replaceStr(value) : replaceStr(otherwise)\n        )\n        // Variables\n        .replaceAll(/\\${(\\w+)}/g,\n            (_all, name) => process.env[name]\n        )\n        // Nix paths\n        .replaceAll(/\\$!{(\\w+)}/g,\n            (_all, exe) => getNixPath(exe)\n        )\n\nexport async function compileTemplate(infile, outfile) {\n    await writeFile(outfile,\n        replaceStr(await readFile(infile, { encoding: 'utf8' })),\n        { encoding: 'utf8' })\n}\n",
-    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nif (isLaravel()) {\n   checkEnvErrors('/app')\n}\n\nawait Promise.all([\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
+    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nawait Promise.all([\n    isLaravel() ? checkEnvErrors('/app') : Promise.resolve(),\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
     "scripts/util/cmd.mjs": "import { execSync } from \"child_process\";\n\nexport const e = cmd => execSync(cmd).toString().replace('\\n', '');",
     "scripts/util/laravel.mjs": "import Logger from \"./logger.mjs\"\nimport * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\n\nconst variableHints = {\n    'APP_ENV': 'You should probably set this to `production`.'\n};\n\nconst logger = new Logger('laravel');\n\nexport const isLaravel = () => process.env['IS_LARAVEL'] != null;\n\nfunction checkVariable(name) {\n    if (!process.env[name]) {\n        let hint =\n            `Your app configuration references the ${name} environment variable, but it is not set.`\n            + (variableHints[name] ?? '');\n\n        logger.warn(hint);\n    }\n}\n\nexport async function checkEnvErrors(srcdir) {\n    const envRegex = /env\\([\"']([^,]*)[\"']\\)/g;\n    const configDir = path.join(srcdir, 'config');\n\n    const config =\n        (await Promise.all(\n            (await fs.readdir(configDir))\n                .filter(fileName => fileName.endsWith('.php'))\n                .map(fileName => fs.readFile(path.join(configDir, fileName)))\n        )).join('');\n\n    for (const match of config.matchAll(envRegex)) {\n        if (match[1] != 'APP_KEY') checkVariable(match[1]);\n    }\n\n    if (!process.env.APP_KEY) {\n        logger.warn('Your app key is not set! Please set a random 32-character string in your APP_KEY environment variable. This can be easily generated with `openssl rand -hex 16`.');\n    }\n}\n",
     "scripts/util/logger.mjs": "export default class Logger {\n    /** @type string */\n    #tag;\n\n    /**\n    * @param {string} tag\n    */\n    constructor(tag) {\n        this.#tag = tag\n    }\n\n    #log(color, messageType, message, fn = console.log) {\n        fn(`\\x1b[${color}m[${this.#tag}:${messageType}]\\x1b[0m ${message}`)\n    }\n\n    info(message) {\n        this.#log(34, 'info', message)\n    }\n\n    warn(message) {\n        this.#log(35, 'warn', message, console.warn)\n    }\n\n    error(message) {\n        this.#log(31, 'error', message, console.error)\n    }\n}\n",
diff --git a/tests/snapshots/generate_plan_tests__php_laravel_ext_mongodb.snap b/tests/snapshots/generate_plan_tests__php_laravel_ext_mongodb.snap
index ed65828..d411afc 100644
--- a/tests/snapshots/generate_plan_tests__php_laravel_ext_mongodb.snap
+++ b/tests/snapshots/generate_plan_tests__php_laravel_ext_mongodb.snap
@@ -15,7 +15,7 @@ expression: plan
     "nginx.template.conf": "worker_processes 5;\ndaemon off;\n\nworker_rlimit_nofile 8192;\n\nevents {\n  worker_connections  4096;  # Default: 1024\n}\n\nhttp {\n    include    $!{nginx}/conf/mime.types;\n    index    index.html index.htm index.php;\n\n    default_type application/octet-stream;\n    log_format   main '$remote_addr - $remote_user [$time_local]  $status '\n        '\"$request\" $body_bytes_sent \"$http_referer\" '\n        '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n    access_log /dev/stdout;\n    error_log /dev/stdout;\n    sendfile     on;\n    tcp_nopush   on;\n    server_names_hash_bucket_size 128; # this seems to be required for some vhosts\n\n    server {\n        listen ${PORT};\n        listen [::]:${PORT};\n        server_name localhost;\n\n        $if(NIXPACKS_PHP_ROOT_DIR) (\n            root ${NIXPACKS_PHP_ROOT_DIR};\n        ) else (\n            root /app;\n        )\n     \n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-Content-Type-Options \"nosniff\";\n     \n        index index.php;\n     \n        charset utf-8;\n     \n        $if(IS_LARAVEL) (\n            location / {\n                try_files $uri $uri/ /index.php?$query_string;\n            }\n        ) else ()\n        \n        $if(NIXPACKS_PHP_FALLBACK_PATH) (\n          location / {\n            try_files $uri $uri/ ${NIXPACKS_PHP_FALLBACK_PATH}?$query_string;\n          }\n        ) else ()\n     \n        location = /favicon.ico { access_log off; log_not_found off; }\n        location = /robots.txt  { access_log off; log_not_found off; }\n     \n        $if(IS_LARAVEL) (\n            error_page 404 /index.php;\n        ) else ()\n     \n        location ~ \\.php$ {\n            fastcgi_pass 127.0.0.1:9000;\n            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n            include $!{nginx}/conf/fastcgi_params;\n            include $!{nginx}/conf/fastcgi.conf;\n        }\n     \n        location ~ /\\.(?!well-known).* {\n            deny all;\n        }\n    }\n}",
     "php-fpm.conf": "[www]\nlisten = 127.0.0.1:9000\nuser = nobody\npm = dynamic\npm.max_children = 50\npm.min_spare_servers = 4\npm.max_spare_servers = 32\npm.start_servers = 18\nclear_env = no\n",
     "scripts/config/template.mjs": "import { readFile, writeFile } from \"fs/promises\";\nimport { getNixPath } from \"../util/nix.mjs\";\n\nconst replaceStr = input =>\n    input\n        // If statements\n        .replaceAll(/\\$if\\s*\\((\\w+)\\)\\s*\\(([^]*?)\\)\\s*else\\s*\\(([^]*?)\\)/gm,\n            (_all, condition, value, otherwise) =>\n                process.env[condition] ? replaceStr(value) : replaceStr(otherwise)\n        )\n        // Variables\n        .replaceAll(/\\${(\\w+)}/g,\n            (_all, name) => process.env[name]\n        )\n        // Nix paths\n        .replaceAll(/\\$!{(\\w+)}/g,\n            (_all, exe) => getNixPath(exe)\n        )\n\nexport async function compileTemplate(infile, outfile) {\n    await writeFile(outfile,\n        replaceStr(await readFile(infile, { encoding: 'utf8' })),\n        { encoding: 'utf8' })\n}\n",
-    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nif (isLaravel()) {\n   checkEnvErrors('/app')\n}\n\nawait Promise.all([\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
+    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nawait Promise.all([\n    isLaravel() ? checkEnvErrors('/app') : Promise.resolve(),\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
     "scripts/util/cmd.mjs": "import { execSync } from \"child_process\";\n\nexport const e = cmd => execSync(cmd).toString().replace('\\n', '');",
     "scripts/util/laravel.mjs": "import Logger from \"./logger.mjs\"\nimport * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\n\nconst variableHints = {\n    'APP_ENV': 'You should probably set this to `production`.'\n};\n\nconst logger = new Logger('laravel');\n\nexport const isLaravel = () => process.env['IS_LARAVEL'] != null;\n\nfunction checkVariable(name) {\n    if (!process.env[name]) {\n        let hint =\n            `Your app configuration references the ${name} environment variable, but it is not set.`\n            + (variableHints[name] ?? '');\n\n        logger.warn(hint);\n    }\n}\n\nexport async function checkEnvErrors(srcdir) {\n    const envRegex = /env\\([\"']([^,]*)[\"']\\)/g;\n    const configDir = path.join(srcdir, 'config');\n\n    const config =\n        (await Promise.all(\n            (await fs.readdir(configDir))\n                .filter(fileName => fileName.endsWith('.php'))\n                .map(fileName => fs.readFile(path.join(configDir, fileName)))\n        )).join('');\n\n    for (const match of config.matchAll(envRegex)) {\n        if (match[1] != 'APP_KEY') checkVariable(match[1]);\n    }\n\n    if (!process.env.APP_KEY) {\n        logger.warn('Your app key is not set! Please set a random 32-character string in your APP_KEY environment variable. This can be easily generated with `openssl rand -hex 16`.');\n    }\n}\n",
     "scripts/util/logger.mjs": "export default class Logger {\n    /** @type string */\n    #tag;\n\n    /**\n    * @param {string} tag\n    */\n    constructor(tag) {\n        this.#tag = tag\n    }\n\n    #log(color, messageType, message, fn = console.log) {\n        fn(`\\x1b[${color}m[${this.#tag}:${messageType}]\\x1b[0m ${message}`)\n    }\n\n    info(message) {\n        this.#log(34, 'info', message)\n    }\n\n    warn(message) {\n        this.#log(35, 'warn', message, console.warn)\n    }\n\n    error(message) {\n        this.#log(31, 'error', message, console.error)\n    }\n}\n",
diff --git a/tests/snapshots/generate_plan_tests__php_vanilla.snap b/tests/snapshots/generate_plan_tests__php_vanilla.snap
index e29909a..112121e 100644
--- a/tests/snapshots/generate_plan_tests__php_vanilla.snap
+++ b/tests/snapshots/generate_plan_tests__php_vanilla.snap
@@ -13,7 +13,7 @@ expression: plan
     "nginx.template.conf": "worker_processes 5;\ndaemon off;\n\nworker_rlimit_nofile 8192;\n\nevents {\n  worker_connections  4096;  # Default: 1024\n}\n\nhttp {\n    include    $!{nginx}/conf/mime.types;\n    index    index.html index.htm index.php;\n\n    default_type application/octet-stream;\n    log_format   main '$remote_addr - $remote_user [$time_local]  $status '\n        '\"$request\" $body_bytes_sent \"$http_referer\" '\n        '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n    access_log /dev/stdout;\n    error_log /dev/stdout;\n    sendfile     on;\n    tcp_nopush   on;\n    server_names_hash_bucket_size 128; # this seems to be required for some vhosts\n\n    server {\n        listen ${PORT};\n        listen [::]:${PORT};\n        server_name localhost;\n\n        $if(NIXPACKS_PHP_ROOT_DIR) (\n            root ${NIXPACKS_PHP_ROOT_DIR};\n        ) else (\n            root /app;\n        )\n     \n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-Content-Type-Options \"nosniff\";\n     \n        index index.php;\n     \n        charset utf-8;\n     \n        $if(IS_LARAVEL) (\n            location / {\n                try_files $uri $uri/ /index.php?$query_string;\n            }\n        ) else ()\n        \n        $if(NIXPACKS_PHP_FALLBACK_PATH) (\n          location / {\n            try_files $uri $uri/ ${NIXPACKS_PHP_FALLBACK_PATH}?$query_string;\n          }\n        ) else ()\n     \n        location = /favicon.ico { access_log off; log_not_found off; }\n        location = /robots.txt  { access_log off; log_not_found off; }\n     \n        $if(IS_LARAVEL) (\n            error_page 404 /index.php;\n        ) else ()\n     \n        location ~ \\.php$ {\n            fastcgi_pass 127.0.0.1:9000;\n            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n            include $!{nginx}/conf/fastcgi_params;\n            include $!{nginx}/conf/fastcgi.conf;\n        }\n     \n        location ~ /\\.(?!well-known).* {\n            deny all;\n        }\n    }\n}",
     "php-fpm.conf": "[www]\nlisten = 127.0.0.1:9000\nuser = nobody\npm = dynamic\npm.max_children = 50\npm.min_spare_servers = 4\npm.max_spare_servers = 32\npm.start_servers = 18\nclear_env = no\n",
     "scripts/config/template.mjs": "import { readFile, writeFile } from \"fs/promises\";\nimport { getNixPath } from \"../util/nix.mjs\";\n\nconst replaceStr = input =>\n    input\n        // If statements\n        .replaceAll(/\\$if\\s*\\((\\w+)\\)\\s*\\(([^]*?)\\)\\s*else\\s*\\(([^]*?)\\)/gm,\n            (_all, condition, value, otherwise) =>\n                process.env[condition] ? replaceStr(value) : replaceStr(otherwise)\n        )\n        // Variables\n        .replaceAll(/\\${(\\w+)}/g,\n            (_all, name) => process.env[name]\n        )\n        // Nix paths\n        .replaceAll(/\\$!{(\\w+)}/g,\n            (_all, exe) => getNixPath(exe)\n        )\n\nexport async function compileTemplate(infile, outfile) {\n    await writeFile(outfile,\n        replaceStr(await readFile(infile, { encoding: 'utf8' })),\n        { encoding: 'utf8' })\n}\n",
-    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nif (isLaravel()) {\n   checkEnvErrors('/app')\n}\n\nawait Promise.all([\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
+    "scripts/prestart.mjs": "#!/usr/bin/env node\nimport { compileTemplate } from \"./config/template.mjs\";\nimport { e } from \"./util/cmd.mjs\";\nimport { checkEnvErrors, isLaravel } from \"./util/laravel.mjs\";\nimport Logger from \"./util/logger.mjs\";\nimport { access, constants } from 'node:fs/promises'\n\nconst prestartLogger = new Logger('prestart');\nconst serverLogger = new Logger('server');\n\nif (process.argv.length != 4) {\n    prestartLogger.error(`Usage: ${process.argv[1]} <config-file> <output-file>`)\n    process.exit(1);\n}\n\nawait Promise.all([\n    isLaravel() ? checkEnvErrors('/app') : Promise.resolve(),\n    access('/app/storage', constants.R_OK)\n        .then(() => e('chmod -R ugo+rw /app/storage'))\n        .catch(() => {}),\n    compileTemplate(process.argv[2], process.argv[3])\n]).catch(err => prestartLogger.error(err));\n\nserverLogger.info(`Server starting on port ${process.env.PORT}`)\n",
     "scripts/util/cmd.mjs": "import { execSync } from \"child_process\";\n\nexport const e = cmd => execSync(cmd).toString().replace('\\n', '');",
     "scripts/util/laravel.mjs": "import Logger from \"./logger.mjs\"\nimport * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\n\nconst variableHints = {\n    'APP_ENV': 'You should probably set this to `production`.'\n};\n\nconst logger = new Logger('laravel');\n\nexport const isLaravel = () => process.env['IS_LARAVEL'] != null;\n\nfunction checkVariable(name) {\n    if (!process.env[name]) {\n        let hint =\n            `Your app configuration references the ${name} environment variable, but it is not set.`\n            + (variableHints[name] ?? '');\n\n        logger.warn(hint);\n    }\n}\n\nexport async function checkEnvErrors(srcdir) {\n    const envRegex = /env\\([\"']([^,]*)[\"']\\)/g;\n    const configDir = path.join(srcdir, 'config');\n\n    const config =\n        (await Promise.all(\n            (await fs.readdir(configDir))\n                .filter(fileName => fileName.endsWith('.php'))\n                .map(fileName => fs.readFile(path.join(configDir, fileName)))\n        )).join('');\n\n    for (const match of config.matchAll(envRegex)) {\n        if (match[1] != 'APP_KEY') checkVariable(match[1]);\n    }\n\n    if (!process.env.APP_KEY) {\n        logger.warn('Your app key is not set! Please set a random 32-character string in your APP_KEY environment variable. This can be easily generated with `openssl rand -hex 16`.');\n    }\n}\n",
     "scripts/util/logger.mjs": "export default class Logger {\n    /** @type string */\n    #tag;\n\n    /**\n    * @param {string} tag\n    */\n    constructor(tag) {\n        this.#tag = tag\n    }\n\n    #log(color, messageType, message, fn = console.log) {\n        fn(`\\x1b[${color}m[${this.#tag}:${messageType}]\\x1b[0m ${message}`)\n    }\n\n    info(message) {\n        this.#log(34, 'info', message)\n    }\n\n    warn(message) {\n        this.#log(35, 'warn', message, console.warn)\n    }\n\n    error(message) {\n        this.#log(31, 'error', message, console.error)\n    }\n}\n",
diff --git a/tests/snapshots/generate_plan_tests__scala_sbt.snap b/tests/snapshots/generate_plan_tests__scala_sbt.snap
index 44f9a9f..453e3f8 100644
--- a/tests/snapshots/generate_plan_tests__scala_sbt.snap
+++ b/tests/snapshots/generate_plan_tests__scala_sbt.snap
@@ -35,7 +35,7 @@ expression: plan
   },
   "start": {
     "cmd": "./target/universal/stage/bin/main",
-    "runImage": "eclipse-temurin:17.0.5_8-jre-jammy",
+    "runImage": "eclipse-temurin:17.0.9_9-jre-jammy",
     "onlyIncludeFiles": [
       "./target/universal"
     ]