Skip to content
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@ All notable changes to the Pollora framework will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased](https://github.com/Pollora/framework/compare/v13.38.0...develop)
## [Unreleased](https://github.com/Pollora/framework/compare/v13.38.1...develop)

## [v13.38.1](https://github.com/Pollora/framework/compare/v13.38.0...v13.38.1) - 2026-10-21

### Fixed
- An `APP_URL` without a scheme (`APP_URL=pollora.test`, written by hand in `.env`) no longer breaks the site: every Artisan command failed with `Undefined array key "host"`, and `/wp-admin/` answered a 404 because WordPress built its URLs relative to the current path. The value is read as `http://pollora.test` for `WP_HOME`, `WP_SITEURL`, the cookie path and the console host (#518)

- The home page of a site served by Valet or Herd no longer redirects to itself. Both run the site through their own `server.php`, which includes `public/index.php`, so `SCRIPT_FILENAME` is not the front controller. Pollora took the request for a WordPress entry point and sent it to the home URL, in an endless loop. A front controller PHP has included now counts as Laravel serving the request
- `/cms/wp-admin/`, the address `pollora:install` prints for the admin, answered a 404 under Valet and Herd, which hand every URL that is not a file to the front controller. A request for a `public/` directory that holds an `index.php` is now sent to that file

### Added
- Site Health reports an error when the web server serves `.env`: MAMP, Apache or nginx pointed at the project folder instead of `public/` expose the application key and the database password, and answer the site with a 404. The check requests `/.env` and says to point the document root to `public/`
- `pollora:doctor` warns when `APP_URL` has no scheme, and gives the value to write

## [v13.38.0](https://github.com/Pollora/framework/compare/v13.37.1...v13.38.0) - 2026-10-21

Expand Down
54 changes: 54 additions & 0 deletions src/Doctor/Infrastructure/Checks/AppUrlCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Pollora\Doctor\Infrastructure\Checks;

use Pollora\Doctor\Domain\Contracts\CheckInterface;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Domain\Models\CheckResult;
use Pollora\WordPress\AppUrl;

/**
* APP_URL names a scheme.
*
* `APP_URL=pollora.test` in a hand-edited .env is read as http://pollora.test,
* but everything else that reads the setting (Laravel's URL helpers in the
* console, mails, a queue worker) gets the bare host.
*/
final readonly class AppUrlCheck implements CheckInterface
{
public function id(): string
{
return 'app-url';
}

public function label(): string
{
return 'Application URL';
}

public function runsIn(): array
{
return [RunContext::Console, RunContext::Http];
}

public function run(RunContext $context): CheckResult
{
$url = trim((string) config('app.url'));

if ($url === '') {
return CheckResult::skipped('APP_URL is not set.');
}

if (! AppUrl::hasScheme($url)) {
return CheckResult::warning(
sprintf('APP_URL (%s) has no scheme.', $url),
[sprintf('Pollora reads it as %s', AppUrl::normalize($url))],
sprintf('Write APP_URL=%s (https:// when the site is served over TLS)', AppUrl::normalize($url)),
);
}

return CheckResult::ok('APP_URL names a scheme.');
}
}
66 changes: 66 additions & 0 deletions src/Doctor/Infrastructure/Checks/PublicRootCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Pollora\Doctor\Infrastructure\Checks;

use Illuminate\Support\Facades\Http;
use Pollora\Doctor\Domain\Contracts\CheckInterface;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Domain\Models\CheckResult;
use Throwable;

/**
* The web server's document root is public/, not the project folder.
*
* MAMP, Apache and nginx are often pointed at the project folder. The site
* then answers 404 and, worse, `/.env` is served as a text file with the
* application key and the database password. Valet, Herd and `artisan serve`
* always use public/, so only a manual setup can get this wrong.
*
* Only a web request can tell which folder the server exposes, so the check
* runs in Site Health. A server that cannot answer its own request (PHP's
* built-in server with a single worker) skips it rather than guess.
*/
final readonly class PublicRootCheck implements CheckInterface
{
public function id(): string
{
return 'public-root';
}

public function label(): string
{
return 'Document root';
}

public function runsIn(): array
{
return [RunContext::Http];
}

public function run(RunContext $context): CheckResult
{
$url = rtrim(trim((string) config('app.url')), '/');

if ($url === '') {
return CheckResult::skipped('APP_URL is not set.');
}

try {
$response = Http::timeout(3)->withoutRedirecting()->get($url.'/.env');
} catch (Throwable) {
return CheckResult::skipped('The site could not request itself.');
}

if ($response->ok() && str_contains($response->body(), 'APP_KEY=')) {
return CheckResult::error(
'.env is served over HTTP: the web server exposes the project folder, not public/.',
[sprintf('GET %s/.env answered 200 with the application settings', $url)],
'Point the document root to the public/ folder of the project (MAMP: Preferences › Server › Document root), then change APP_KEY and the passwords in .env',
);
}

return CheckResult::ok('.env is not served; the document root is public/.');
}
}
4 changes: 4 additions & 0 deletions src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Illuminate\Support\ServiceProvider;
use Pollora\Doctor\Application\Services\Doctor;
use Pollora\Doctor\Infrastructure\Checks\AppUrlCheck;
use Pollora\Doctor\Infrastructure\Checks\AssetBuildCheck;
use Pollora\Doctor\Infrastructure\Checks\BlockRegistrationCheck;
use Pollora\Doctor\Infrastructure\Checks\BlockThemeRoutesCheck;
Expand All @@ -16,6 +17,7 @@
use Pollora\Doctor\Infrastructure\Checks\PatchesLockCheck;
use Pollora\Doctor\Infrastructure\Checks\PatternCacheCheck;
use Pollora\Doctor\Infrastructure\Checks\PatternFilesCheck;
use Pollora\Doctor\Infrastructure\Checks\PublicRootCheck;
use Pollora\Doctor\Infrastructure\Checks\SymlinkedDirectoryCheck;
use Pollora\Doctor\Infrastructure\Checks\TemplatePlaceholdersCheck;
use Pollora\Doctor\Infrastructure\Checks\WordPressCorePatchCheck;
Expand All @@ -39,6 +41,7 @@ class DoctorServiceProvider extends ServiceProvider
WordPressCorePatchCheck::class,
PatchesLockCheck::class,
EnvironmentFileCheck::class,
AppUrlCheck::class,
DevelopmentCachesCheck::class,
DiscoveryCacheCheck::class,
AssetBuildCheck::class,
Expand All @@ -49,6 +52,7 @@ class DoctorServiceProvider extends ServiceProvider
BlockThemeRoutesCheck::class,
LegacyBlocksDirectoryCheck::class,
BlockRegistrationCheck::class,
PublicRootCheck::class,
];

public function register(): void
Expand Down
10 changes: 9 additions & 1 deletion src/Route/UI/Http/Controllers/FrontendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
namespace Pollora\Route\UI\Http\Controllers;

use Illuminate\Foundation\Exceptions\RegisterErrorViewPaths;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\View;
use Pollora\Route\Application\Services\AnsweringTemplate;
use Pollora\Route\Domain\Enums\TemplateOutcome;
use Pollora\Route\Domain\Models\TemplateResolution;
use Pollora\Route\UI\Http\Responses\PublicDirectoryIndexRedirect;
use Pollora\Route\UI\Http\Responses\SetupRequiredResponse;
use Pollora\View\Domain\Contracts\TemplateFinderInterface;

Expand Down Expand Up @@ -48,8 +50,14 @@ public function __construct(
/**
* Handle the request using WordPress template hierarchy.
*/
public function handle(Request $request): Response
public function handle(Request $request): Response|RedirectResponse
{
// A directory of public/ (the admin at /cms/wp-admin/) has an index.php
// a document-root server runs by itself; Valet and Herd do not
if (($redirect = PublicDirectoryIndexRedirect::for($request, public_path())) instanceof RedirectResponse) {
return $redirect;
}

// WordPress loads once the database is configured: until then there is
// no hierarchy to ask (Pollora/pollora#78)
if (! $this->wordPressIsLoaded()) {
Expand Down
55 changes: 55 additions & 0 deletions src/Route/UI/Http/Responses/PublicDirectoryIndexRedirect.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\UI\Http\Responses;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

/**
* Send a request for a `public/` directory to the `index.php` inside it.
*
* A web server with a document root answers `/cms/wp-admin/` by running that
* directory's index.php. Valet and Herd hand every URL that is not a file to
* the front controller instead, so the request reached the WordPress fallback
* and answered 404 — on the very address `pollora:install` prints for the
* admin. The redirect reaches the same file explicitly, which every server
* serves.
*/
final class PublicDirectoryIndexRedirect
{
public static function for(Request $request, string $publicPath): ?RedirectResponse
{
if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) {
return null;
}

$path = rawurldecode($request->getPathInfo());

if ($path === '/' || ! str_ends_with($path, '/') || str_contains($path, '..')) {
return null;
}

// The content folders answer the site's 404 on purpose (see the
// skeleton's .htaccess): redirecting would tell a visitor they exist
if (preg_match('#^/(cms/wp-content|content)(/|$)#', $path) === 1) {
return null;
}

$root = realpath($publicPath);
$directory = realpath($publicPath.$path);

if ($root === false || $directory === false || ! str_starts_with($directory, $root.DIRECTORY_SEPARATOR)) {
return null;
}

if (! is_file($directory.DIRECTORY_SEPARATOR.'index.php')) {
return null;
}

$query = $request->getQueryString();

return new RedirectResponse($request->getBaseUrl().$request->getPathInfo().'index.php'.($query !== null && $query !== '' ? '?'.$query : ''));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Config;
use Pollora\Hook\Domain\Contract\Filter;
use Pollora\WordPress\AppUrl;

/**
* Service for loading WordPress core files and initializing the environment.
Expand Down Expand Up @@ -153,10 +154,10 @@ private function loadAdminFiles(): self
*/
private function defineConstants(): self
{
$appUrl = Config::get('app.url');
$appUrl = AppUrl::normalize((string) Config::get('app.url'));

if (! defined('COOKIEHASH')) {
define('COOKIEHASH', md5((string) $appUrl));
define('COOKIEHASH', md5($appUrl));
}

$cookiePath = $this->getCookiePath($appUrl);
Expand Down
42 changes: 42 additions & 0 deletions src/WordPress/AppUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Pollora\WordPress;

/**
* The application's URL, as WordPress needs it.
*
* `APP_URL=pollora.test` is a mistake a hand-edited .env lets through. Read as
* it is, WordPress builds every URL relative to the current path and the
* console cannot find the host. A value without a scheme is read as http://,
* the scheme of a local site that was not secured.
*/
final class AppUrl
{
public static function normalize(string $url): string
{
$url = trim($url);

if ($url === '' || self::hasScheme($url)) {
return $url;
}

return 'http://'.ltrim($url, '/');
}

public static function hasScheme(string $url): bool
{
return preg_match('#^[a-z][a-z0-9+.-]*://#i', trim($url)) === 1;
}

/**
* The host of the URL, without its port; null when there is none.
*/
public static function host(string $url): ?string
{
$host = parse_url(self::normalize($url), PHP_URL_HOST);

return is_string($host) && $host !== '' ? $host : null;
}
}
8 changes: 4 additions & 4 deletions src/WordPress/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -468,11 +468,11 @@ private function setLocationConstants(): void
Constant::queue('ABSPATH', $basePath.$wpPath);
}

$appUrl = config('app.url');
Constant::queue('WP_SITEURL', rtrim((string) $appUrl, '/').'/'.ltrim(str_replace('public/', '', $wpPath), '/'));
$appUrl = AppUrl::normalize((string) config('app.url'));
Constant::queue('WP_SITEURL', rtrim($appUrl, '/').'/'.ltrim(str_replace('public/', '', $wpPath), '/'));
Constant::queue('WP_HOME', $appUrl);
Constant::queue('WP_CONTENT_DIR', $basePath.$contentPath);
Constant::queue('WP_CONTENT_URL', rtrim((string) $appUrl, '/').'/content');
Constant::queue('WP_CONTENT_URL', rtrim($appUrl, '/').'/content');

// Apply constants once all are queued
Constant::apply();
Expand All @@ -481,6 +481,6 @@ private function setLocationConstants(): void
private function setConsoleServerVariables(): void
{
$_SERVER['SERVER_PROTOCOL'] = 'https';
$_SERVER['HTTP_HOST'] = parse_url((string) config('app.url'))['host'];
$_SERVER['HTTP_HOST'] = AppUrl::host((string) config('app.url')) ?? 'localhost';
}
}
3 changes: 2 additions & 1 deletion src/WordPress/Commands/LaunchPadInstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Pollora\Services\WordPress\Installation\DTO\InstallationConfig;
use Pollora\Services\WordPress\Installation\InstallationService;
use Pollora\Services\WordPress\Installation\WordPressInstallationException;
use Pollora\WordPress\AppUrl;

use function Laravel\Prompts\error;
use function Laravel\Prompts\info;
Expand Down Expand Up @@ -152,7 +153,7 @@ private function installWordPress(bool $silent = false): void
*/
private function projectName(): string
{
$host = parse_url((string) config('app.url'), PHP_URL_HOST);
$host = AppUrl::host((string) config('app.url'));

if (is_string($host) && str_contains($host, '.')) {
return explode('.', $host)[0];
Expand Down
9 changes: 8 additions & 1 deletion src/WordPress/QueryTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ abstract private function withWordPressErrorHandling(callable $callback): void;
* The test is which script PHP is running, not a list of filenames: any
* entry point WordPress grows later is covered without being named, and a
* project adding its own is too.
*
* A server may start another script that includes the front controller:
* Valet and Herd run their own `server.php`, which requires
* `public/index.php`, so `SCRIPT_FILENAME` is not the front controller
* there. The request is still Laravel's, and a front controller PHP has
* included proves it — WordPress's entry points never include it.
*/
private function laravelIsServingTheRequest(): bool
{
Expand All @@ -193,6 +199,7 @@ private function laravelIsServingTheRequest(): bool
return true;
}

return $running === $frontController;
return $running === $frontController
|| in_array($frontController, array_map(realpath(...), get_included_files()), true);
}
}
Loading
Loading