diff --git a/src/Route.php b/src/Route.php
index 9d7636d..2de2c8f 100644
--- a/src/Route.php
+++ b/src/Route.php
@@ -300,7 +300,11 @@ public static function setNotFoundHandler($handler): void
*/
public static function run(): void
{
- $uri = Url::uri();
+ // Match against the request path relative to the app's own
+ // subdirectory (Url::requestPath()), not the raw request URI, so
+ // routes keep matching whether the app is installed at the domain
+ // root or under a subfolder (e.g. http://example.com/blog/).
+ $uri = Url::requestPath();
$method = self::resolveMethod();
$matchedRoute = null;
diff --git a/src/Url.php b/src/Url.php
index f6d6069..90f6802 100644
--- a/src/Url.php
+++ b/src/Url.php
@@ -16,6 +16,7 @@
class Url
{
private static ?string $baseUrl = null;
+ private static ?string $basePath = null;
private static ?array $parsedUrl = null;
/**
@@ -99,6 +100,26 @@ public static function base(): string
return self::$baseUrl;
}
+ return self::$baseUrl = self::home() . self::basePath();
+ }
+
+ /**
+ * Get the subdirectory the application is installed under, relative to
+ * the web server's document root (e.g. "/blog" for an app installed at
+ * http://example.com/blog/, or "" when installed at the domain root).
+ *
+ * Derived by diffing the application's root path against DOCUMENT_ROOT,
+ * so it works regardless of how deep the install sits under the document
+ * root.
+ *
+ * @return string Subdirectory path, without a trailing slash
+ */
+ public static function basePath(): string
+ {
+ if (self::$basePath !== null) {
+ return self::$basePath;
+ }
+
$documentRoot = self::documentRoot();
$scriptPath = App::getRootPath();
@@ -108,10 +129,34 @@ public static function base(): string
$scriptPath = substr($scriptPath, $position);
}
- $subdirectory = substr($scriptPath, strlen($documentRoot));
- self::$baseUrl = self::home() . $subdirectory;
+ return self::$basePath = substr($scriptPath, strlen($documentRoot));
+ }
+
+ /**
+ * Get the current request URI relative to the application's own
+ * subdirectory, i.e. with the base path from {@see basePath()} stripped
+ * off the front.
+ *
+ * This is what routing should match against: for an app installed at
+ * http://example.com/blog/, a request for /blog/about resolves here to
+ * /about, exactly as it would if the app were installed at the domain
+ * root. When the app is installed at the domain root, this is identical
+ * to {@see uri()}.
+ *
+ * @return string Request path relative to the application base path
+ */
+ public static function requestPath(): string
+ {
+ $uri = self::uri();
+ $basePath = self::basePath();
+
+ if ($basePath === '' || !str_starts_with($uri, $basePath)) {
+ return $uri;
+ }
+
+ $path = substr($uri, strlen($basePath));
- return self::$baseUrl;
+ return $path === '' ? '/' : $path;
}
/**
@@ -694,6 +739,7 @@ public static function userAgent(): string
public static function reset(): void
{
self::$baseUrl = null;
+ self::$basePath = null;
self::$parsedUrl = null;
}
diff --git a/src/Vite.php b/src/Vite.php
index ef6a813..6adac56 100644
--- a/src/Vite.php
+++ b/src/Vite.php
@@ -78,6 +78,16 @@ protected function getManifestPath(): string
return $this->basePath . '/public/build/.vite/manifest.json';
}
+ /**
+ * Get the URL prefix for built assets, prepending the application's
+ * subdirectory (if any) so asset tags resolve correctly whether the app
+ * is installed at the domain root or under a subfolder.
+ */
+ protected function getAssetUrlPrefix(): string
+ {
+ return Url::basePath() . self::PRODUCTION_ASSET_BASE_PATH;
+ }
+
/**
* Generate HTML tags for assets
*/
@@ -154,23 +164,24 @@ protected function renderProductionTags(string $entryPoint): string
$entryData = $manifest[$entryPoint];
$output = '';
+ $assetUrlPrefix = $this->getAssetUrlPrefix();
// 1. CSS files (if styles are extracted)
if (isset($entryData['css']) && is_array($entryData['css'])) {
foreach ($entryData['css'] as $cssFile) {
$output .= sprintf(
'' . PHP_EOL,
- self::PRODUCTION_ASSET_BASE_PATH,
+ $assetUrlPrefix,
$cssFile
);
}
}
-
+
// 2. Main JS file
if (isset($entryData['file'])) {
$output .= sprintf(
'',
- self::PRODUCTION_ASSET_BASE_PATH,
+ $assetUrlPrefix,
$entryData['file']
);
}
diff --git a/tests/RouteTest.php b/tests/RouteTest.php
index b91e2f3..79aca14 100644
--- a/tests/RouteTest.php
+++ b/tests/RouteTest.php
@@ -1119,6 +1119,37 @@ public function testMiddlewareClassAtMethodCanReturnViewString(): void
$this->assertSame('
Stub view
', $result);
$this->assertNotTrue($result);
}
+
+ // =========================================================================
+ // 13. Subfolder installs (regression: run() must match against the
+ // request path relative to the app's own base path, not the raw URI)
+ // =========================================================================
+
+ /**
+ * Regression guard for subfolder installs: when the app is not installed
+ * at the domain root (e.g. http://example.com/blog/), REQUEST_URI carries
+ * the "/blog" prefix, but registered routes never do. run() must resolve
+ * its match target through Url::requestPath() — which strips that prefix
+ * — rather than the raw Url::uri(), or every route would need to be
+ * hand-prefixed with the install subdirectory.
+ *
+ * run() itself calls Header::respond(), which echoes and exits, so it
+ * cannot be invoked in-process; this asserts on the source the same way
+ * testRunReturnsAfterMiddlewareFailureSoHandlerNeverRuns() does above.
+ */
+ public function testRunMatchesAgainstRequestPathNotRawUri(): void
+ {
+ $source = (new \ReflectionMethod(Route::class, 'run'))->getFileName();
+ $start = (new \ReflectionMethod(Route::class, 'run'))->getStartLine();
+ $end = (new \ReflectionMethod(Route::class, 'run'))->getEndLine();
+ $body = implode('', array_slice(file($source), $start - 1, $end - $start + 1));
+
+ $this->assertStringContainsString(
+ 'Url::requestPath()',
+ $body,
+ 'run() must match against Url::requestPath(), which is relative to the app\'s base path, so subfolder installs route correctly.'
+ );
+ }
}
/**
diff --git a/tests/UrlTest.php b/tests/UrlTest.php
index 9ecda16..164cc61 100644
--- a/tests/UrlTest.php
+++ b/tests/UrlTest.php
@@ -1001,6 +1001,100 @@ public function testInputPutJsonWithNullableFields(): void
$this->assertSame('Test', $result['title']);
}
+ // =========================================================================
+ // 23. basePath() / requestPath() / base() — subfolder installs
+ // =========================================================================
+ //
+ // App::setRootPath() is a separate static that Url::reset() does not
+ // touch, so every test in this section sets it explicitly (same pattern
+ // as DirectoryTest) rather than relying on state left over by another
+ // test.
+
+ public function testBasePathIsEmptyWhenAppRootIsTheDocumentRoot(): void
+ {
+ $root = sys_get_temp_dir() . '/webrium_url_test_' . uniqid();
+ mkdir($root, 0755, true);
+ \Webrium\App::setRootPath($root);
+ $_SERVER['DOCUMENT_ROOT'] = $root;
+
+ $this->assertSame('', Url::basePath());
+
+ rmdir($root);
+ }
+
+ public function testBasePathReturnsSubdirectoryWhenAppIsInstalledUnderIt(): void
+ {
+ $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid();
+ $appRoot = $documentRoot . '/blog';
+ mkdir($appRoot, 0755, true);
+ \Webrium\App::setRootPath($appRoot);
+ $_SERVER['DOCUMENT_ROOT'] = $documentRoot;
+
+ $this->assertSame('/blog', Url::basePath());
+
+ rmdir($appRoot);
+ rmdir($documentRoot);
+ }
+
+ public function testBaseAppendsBasePathToHome(): void
+ {
+ $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid();
+ $appRoot = $documentRoot . '/blog';
+ mkdir($appRoot, 0755, true);
+ \Webrium\App::setRootPath($appRoot);
+ $_SERVER['DOCUMENT_ROOT'] = $documentRoot;
+ $_SERVER['HTTP_HOST'] = 'example.com';
+
+ $this->assertSame('http://example.com/blog', Url::base());
+
+ rmdir($appRoot);
+ rmdir($documentRoot);
+ }
+
+ public function testRequestPathStripsBasePathForSubfolderInstall(): void
+ {
+ $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid();
+ $appRoot = $documentRoot . '/blog';
+ mkdir($appRoot, 0755, true);
+ \Webrium\App::setRootPath($appRoot);
+ $_SERVER['DOCUMENT_ROOT'] = $documentRoot;
+ $_SERVER['REQUEST_URI'] = '/blog/posts/42';
+
+ $this->assertSame('/posts/42', Url::requestPath());
+
+ rmdir($appRoot);
+ rmdir($documentRoot);
+ }
+
+ public function testRequestPathOfSubfolderRootResolvesToSlash(): void
+ {
+ $documentRoot = sys_get_temp_dir() . '/webrium_url_test_' . uniqid();
+ $appRoot = $documentRoot . '/blog';
+ mkdir($appRoot, 0755, true);
+ \Webrium\App::setRootPath($appRoot);
+ $_SERVER['DOCUMENT_ROOT'] = $documentRoot;
+ $_SERVER['REQUEST_URI'] = '/blog';
+
+ $this->assertSame('/', Url::requestPath());
+
+ rmdir($appRoot);
+ rmdir($documentRoot);
+ }
+
+ public function testRequestPathMatchesUriWhenAppIsAtDocumentRoot(): void
+ {
+ $root = sys_get_temp_dir() . '/webrium_url_test_' . uniqid();
+ mkdir($root, 0755, true);
+ \Webrium\App::setRootPath($root);
+ $_SERVER['DOCUMENT_ROOT'] = $root;
+ $_SERVER['REQUEST_URI'] = '/posts/42';
+
+ $this->assertSame(Url::uri(), Url::requestPath());
+ $this->assertSame('/posts/42', Url::requestPath());
+
+ rmdir($root);
+ }
+
// =========================================================================
// Test Infrastructure (private helpers)
// =========================================================================