From 5c91cee8c04e81bab6dcf249a3bb4820f6060ec0 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Tue, 27 Oct 2026 13:52:32 +0600 Subject: [PATCH 1/2] Add a Contract for URL Generation Services --- src/Phaseolies/Launchers/FacadeLauncher.php | 9 +- .../Contracts/UrlGeneratorInterface.php | 114 ++++++++++++++++++ src/Phaseolies/Support/UrlGenerator.php | 5 +- tests/UrlGeneratorTest.php | 41 +++++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php diff --git a/src/Phaseolies/Launchers/FacadeLauncher.php b/src/Phaseolies/Launchers/FacadeLauncher.php index 5dbb30c9..fa80e2b6 100644 --- a/src/Phaseolies/Launchers/FacadeLauncher.php +++ b/src/Phaseolies/Launchers/FacadeLauncher.php @@ -4,6 +4,7 @@ use Phaseolies\Support\Validation\Sanitizer; use Phaseolies\Support\UrlGenerator; +use Phaseolies\Support\Contracts\UrlGeneratorInterface; use Phaseolies\Support\ViteManager; use Phaseolies\Support\StringService; use Phaseolies\Support\Storage\StorageFileService; @@ -70,12 +71,12 @@ public function register() // This handles sending emails. $this->app->singleton('mail', MailService::class); - // Bind the 'url' service to a singleton instance of the UrlGenerator class. - // This generates URLs for the application, using the base URL from the environment - // or configuration. - $this->app->singleton('url', function () { + // Bind the URL generator contract to a singleton implementation and + // retain the existing 'url' service name as an alias. + $this->app->singleton(UrlGeneratorInterface::class, function () { return new UrlGenerator(env('APP_URL') ?? config('app.url')); }); + $this->app->alias(UrlGeneratorInterface::class, 'url'); // Bind the 'storage' service to a singleton instance of the Storage class. // This handles file uploads. diff --git a/src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php b/src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php new file mode 100644 index 00000000..e499af3a --- /dev/null +++ b/src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php @@ -0,0 +1,114 @@ +urlGenerator = new UrlGenerator($this->baseUrl); } + public function testUrlGeneratorImplementationConformsToTheContract(): void + { + $this->assertInstanceOf(UrlGeneratorInterface::class, $this->urlGenerator); + } + + public function testApplicationBindsTheUrlContractAndLegacyServiceNameToTheSameSingleton(): void + { + $originalAppUrl = getenv('APP_URL'); + putenv('APP_URL=http://localhost'); + + try { + $app = $this->getMockBuilder(Application::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $containerConstructor = (new ReflectionClass(Container::class))->getConstructor(); + $containerConstructor->invoke($app); + + (new FacadeLauncher($app))->register(); + + $contractUrl = $app->get(UrlGeneratorInterface::class); + $legacyUrl = $app->get('url'); + + $this->assertInstanceOf(UrlGeneratorInterface::class, $contractUrl); + $this->assertInstanceOf(UrlGenerator::class, $contractUrl); + $this->assertSame($contractUrl, $legacyUrl); + } finally { + if ($originalAppUrl === false) { + putenv('APP_URL'); + } else { + putenv("APP_URL={$originalAppUrl}"); + } + } + } + protected function tearDown(): void { unset($_SERVER['HTTP_HOST']); From 037f44e4f0df4e18c75b7102b84eb8b3873922f4 Mon Sep 17 00:00:00 2001 From: techmahedy Date: Fri, 30 Oct 2026 20:21:06 +0600 Subject: [PATCH 2/2] Address review: accept mixed in isValid, resolve url() via contract, reset builder state --- src/Phaseolies/Helpers/helpers.php | 6 ++-- .../Contracts/UrlGeneratorInterface.php | 6 ++-- src/Phaseolies/Support/Facades/URL.php | 16 +++++----- src/Phaseolies/Support/UrlGenerator.php | 25 +++++++++++++-- tests/UrlGeneratorTest.php | 31 +++++++++++++++++++ 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index 8b081e18..0617c08f 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -2,7 +2,7 @@ use Phaseolies\Utilities\Paginator; use Phaseolies\Translation\Translator; -use Phaseolies\Support\UrlGenerator; +use Phaseolies\Support\Contracts\UrlGeneratorInterface; use Phaseolies\Support\StringService; use Phaseolies\Support\Facades\Log; use Phaseolies\Support\Facades\Crypt; @@ -194,11 +194,11 @@ function lang(): Translator * @param string|null $path * @param mixed $parameters * @param bool|null $secure - * @return ($path is null ? \Phaseolies\Support\UrlGenerator : string) + * @return ($path is null ? \Phaseolies\Support\Contracts\UrlGeneratorInterface : string) */ function url(?string $path = null, $parameters = [], ?bool $secure = null) { - $urlGenerator = app(UrlGenerator::class); + $urlGenerator = app(UrlGeneratorInterface::class); if (is_null($path)) { return $urlGenerator; diff --git a/src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php b/src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php index e499af3a..30c0198f 100644 --- a/src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php +++ b/src/Phaseolies/Support/Contracts/UrlGeneratorInterface.php @@ -14,7 +14,7 @@ interface UrlGeneratorInterface public function enqueue(string $path = '/', ?bool $secure = null): string; /** - * Generate the current full URL without query parameters. + * Generate the current full URL, including query parameters. * * @return string */ @@ -92,10 +92,10 @@ public function signed(string $path = '/', array|string|int $parameters = [], in /** * Determine whether a value is a valid URL. * - * @param string $path + * @param mixed $path * @return bool */ - public function isValid(string $path): bool; + public function isValid(mixed $path): bool; /** * Get the configured base URL. diff --git a/src/Phaseolies/Support/Facades/URL.php b/src/Phaseolies/Support/Facades/URL.php index 99635aaa..e43e417e 100644 --- a/src/Phaseolies/Support/Facades/URL.php +++ b/src/Phaseolies/Support/Facades/URL.php @@ -5,21 +5,21 @@ use Phaseolies\Facade\BaseFacade; /** - * @method static string enqueue(string $path = '/', $secure = null) + * @method static string enqueue(string $path = '/', ?bool $secure = null) * @method static string full() * @method static string current() * @method static string route(string $name, array|string|int $parameters = [], ?bool $secure = null) - * @method static \Phaseolies\Support\UrlGenerator to($path = '/') - * @method static \Phaseolies\Support\UrlGenerator withQuery(array|string $query = []) - * @method static \Phaseolies\Support\UrlGenerator withSignature($expiration = 3600) - * @method static \Phaseolies\Support\UrlGenerator withFragment($fragment = '') + * @method static \Phaseolies\Support\Contracts\UrlGeneratorInterface to(string $path = '/', array|string|int $parameters = [], ?bool $secure = null) + * @method static \Phaseolies\Support\Contracts\UrlGeneratorInterface withQuery(array|string $query = []) + * @method static \Phaseolies\Support\Contracts\UrlGeneratorInterface withSignature(int $expiration = 3600) + * @method static \Phaseolies\Support\Contracts\UrlGeneratorInterface withFragment(string $fragment = '') * @method static string make() * @method static string signed(string $path = '/', array|string|int $parameters = [], int $expiration = 3600, ?bool $secure = null) - * @method static bool isValid(string $url) + * @method static bool isValid(mixed $url) * @method static string base() - * @method static \Phaseolies\Support\UrlGenerator setSecure(bool $secure) + * @method static \Phaseolies\Support\Contracts\UrlGeneratorInterface setSecure(bool $secure) * - * @see \Phaseolies\Support\UrlGenerator + * @see \Phaseolies\Support\Contracts\UrlGeneratorInterface */ class URL extends BaseFacade { diff --git a/src/Phaseolies/Support/UrlGenerator.php b/src/Phaseolies/Support/UrlGenerator.php index 729da171..92289c82 100644 --- a/src/Phaseolies/Support/UrlGenerator.php +++ b/src/Phaseolies/Support/UrlGenerator.php @@ -104,7 +104,7 @@ public function enqueue(string $path = '/', ?bool $secure = null): string } /** - * Generate a full URL without query parameters. + * Generate the current full URL, including query parameters. * * @return string */ @@ -242,9 +242,24 @@ public function make(): string $url .= '#' . $this->fragment; } + $this->resetBuilderState(); + return $url; } + /** + * Reset the per-URL builder state + * + * @return void + */ + protected function resetBuilderState(): void + { + $this->path = '/'; + $this->query = []; + $this->fragment = ''; + $this->expiration = 0; + } + /** * Generate a signed URL. * @@ -282,11 +297,15 @@ protected function createSignature(array $parameters): string * This method is taken from PHP Laravel framework * Determine if the given path is a valid URL. * - * @param string $path + * @param mixed $path * @return bool */ - public function isValid(string $path): bool + public function isValid(mixed $path): bool { + if (! is_string($path)) { + return false; + } + if (! preg_match('~^(#|//|https?://|(mailto|tel|sms):)~', $path)) { return filter_var($path, FILTER_VALIDATE_URL) !== false; } diff --git a/tests/UrlGeneratorTest.php b/tests/UrlGeneratorTest.php index 4f48f5a3..ee2c4cf5 100644 --- a/tests/UrlGeneratorTest.php +++ b/tests/UrlGeneratorTest.php @@ -62,6 +62,37 @@ public function testApplicationBindsTheUrlContractAndLegacyServiceNameToTheSameS } } + public function testIsValidReturnsFalseForNonStringValues(): void + { + $this->assertFalse($this->urlGenerator->isValid(null)); + $this->assertFalse($this->urlGenerator->isValid(123)); + $this->assertFalse($this->urlGenerator->isValid(['https://example.com'])); + } + + public function testBuilderStateDoesNotLeakBetweenUrls(): void + { + $this->urlGenerator->to('first')->withQuery(['id' => 1])->withFragment('top')->make(); + + $this->assertEquals('http://localhost/second', $this->urlGenerator->to('second')->make()); + } + + public function testUrlHelperResolvesTheSharedSingleton(): void + { + $app = $this->getMockBuilder(Application::class)->disableOriginalConstructor()->onlyMethods([])->getMock(); + (new ReflectionClass(Container::class))->getConstructor()->invoke($app); + $previous = Container::getInstance(); + Container::setInstance($app); + + try { + putenv('APP_URL=http://localhost'); + (new FacadeLauncher($app))->register(); + + $this->assertSame($app->get('url'), url()); + } finally { + Container::setInstance($previous); + } + } + protected function tearDown(): void { unset($_SERVER['HTTP_HOST']);