Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ 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)

### Added
- `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.');
}
}
2 changes: 2 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 Down Expand Up @@ -39,6 +40,7 @@ class DoctorServiceProvider extends ServiceProvider
WordPressCorePatchCheck::class,
PatchesLockCheck::class,
EnvironmentFileCheck::class,
AppUrlCheck::class,
DevelopmentCachesCheck::class,
DiscoveryCacheCheck::class,
AssetBuildCheck::class,
Expand Down
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
25 changes: 25 additions & 0 deletions tests/Feature/Doctor/InstallChecksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Pollora\Discovery\Infrastructure\Adapters\LaravelDiscoveryCacheManagerFactory;
use Pollora\Discovery\Infrastructure\Services\DiscoveryCacheManager;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Infrastructure\Checks\AppUrlCheck;
use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck;
use Pollora\Doctor\Infrastructure\Checks\EnvironmentFileCheck;
use Pollora\Doctor\Infrastructure\Checks\PatchesLockCheck;
Expand Down Expand Up @@ -154,6 +155,30 @@ function writeLock(string $project, array $patches): void
});
});

describe('Application URL', function (): void {
it('warns when APP_URL has no scheme, and says what Pollora reads', function (): void {
config(['app.url' => 'pollora.test']);

$result = (new AppUrlCheck)->run(RunContext::Console);

expect($result->status->value)->toBe('warning')
->and($result->details)->toContain('Pollora reads it as http://pollora.test')
->and($result->fix)->toContain('APP_URL=http://pollora.test');
});

it('passes a URL with a scheme, and a port', function (string $url): void {
config(['app.url' => $url]);

expect((new AppUrlCheck)->run(RunContext::Console)->status->value)->toBe('ok');
})->with(['https://pollora.test', 'http://localhost:8000']);

it('is skipped without an APP_URL', function (): void {
config(['app.url' => '']);

expect((new AppUrlCheck)->run(RunContext::Console)->status->value)->toBe('skipped');
});
});

describe('Discovery cache', function (): void {
function discoveryCheck(bool $cacheEnabled, array $missing): DiscoveryCacheCheck
{
Expand Down
35 changes: 35 additions & 0 deletions tests/Unit/WordPress/AppUrlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

use Pollora\WordPress\AppUrl;

describe('normalize', function (): void {
it('adds http:// to a URL without a scheme', function (string $given, string $expected): void {
expect(AppUrl::normalize($given))->toBe($expected);
})->with([
'a host' => ['pollora.test', 'http://pollora.test'],
'a host and a port' => ['localhost:8888', 'http://localhost:8888'],
'a host with a path' => ['pollora.test/site', 'http://pollora.test/site'],
'a protocol-relative URL' => ['//pollora.test', 'http://pollora.test'],
'spaces around' => [' pollora.test ', 'http://pollora.test'],
]);

it('leaves a URL that has a scheme alone', function (string $url): void {
expect(AppUrl::normalize($url))->toBe($url);
})->with(['http://pollora.test', 'https://pollora.test', 'https://pollora.test/', 'HTTPS://pollora.test']);

it('leaves an empty value alone', function (): void {
expect(AppUrl::normalize(''))->toBe('');
});
});

describe('host', function (): void {
it('finds the host with and without a scheme', function (string $url): void {
expect(AppUrl::host($url))->toBe('pollora.test');
})->with(['pollora.test', 'http://pollora.test', 'https://pollora.test:8443/cms', 'pollora.test:8000']);

it('has no host for an empty value', function (): void {
expect(AppUrl::host(''))->toBeNull();
});
});
Loading