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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Apply approved adaptations when porting or modifying code. Do not expand a chang
### Documentation

- **Use one source of truth** — Put all user documentation in `src/docs/`. Package READMEs are intentionally minimal, not a second documentation surface, and must not duplicate user documentation.
- **Write user documentation in Laravel-docs prose** — Use the simple, direct, human-friendly style of first-party Laravel documentation. Before drafting, read and match the surrounding prose; for new pages, use `src/docs/processes.md` as a style reference. Prefer natural explanations and examples over implementation language; avoid internal jargon, stiff wording, and needless detail.
- **Write user documentation in Laravel-docs prose** — Use the simple, direct, human-friendly style of first-party Laravel documentation. Before drafting, read and match the surrounding prose; for new pages, use `src/docs/processes.md` as a style reference. Explain how to use the API in natural prose and examples. Only include implementation details when they affect how the reader must use it. Before finishing, remove repeated guidance, unnecessary caveats, and explanations already clear from the example.
- **Keep the Laravel porting guide current and focused** — Whenever a framework change means existing Laravel application or package code, configuration, integrations, or data require adaptation or a compatibility check, update `src/docs/porting-from-laravel.md` in the same change. Before adding an entry, ask: “What existing Laravel usage would someone port incorrectly without this entry?” Purely additive, opt-in enhancements belong in feature documentation, not this guide. An enhancement that also changes compatibility qualifies because of that difference, not because it adds functionality. Do not document bug fixes, internal implementation differences, performance work, incidental source drift, or narrow edge cases unless they change what a porter needs to do. Briefly identify the affected usage and required action or risk, then link to the relevant canonical documentation instead of repeating its detail. The guide is a high-signal starting context for humans and LLMs, not an exhaustive framework diff or feature catalogue. Treat its context size as a design constraint, and remove stale or duplicated guidance whenever editing it.

#### Package READMEs
Expand Down
22 changes: 20 additions & 2 deletions src/docs/urls.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,22 @@ URL::useAssetOrigin('https://cdn.example.com');

The `useOrigin` and `useAssetOrigin` methods are isolated to the current request, job, or command. The `forceScheme` method configures the URL generator and is typically called from a service provider.

If you need to change the origin temporarily, you may save the current value using the `getForcedOrigin` method and restore it when your work finishes:

```php
$previous = URL::getForcedOrigin();

try {
URL::useOrigin('https://docs.example.com');

// Generate URLs using the temporary origin...
} finally {
URL::useOrigin($previous);
}
```

The method returns `null` if no origin has been set using `useOrigin`. You may use `getForcedAssetOrigin` with `useAssetOrigin` in the same way for asset URLs.

If your application determines its URL origin from the current context, you may register an origin resolver in a service provider:

```php
Expand Down Expand Up @@ -491,9 +507,11 @@ You may also replace the defaults for the current request, job, or command using
URL::useDefaults(['locale' => 'fr']);
```

Calling `useDefaults` again replaces the previous values. Pass `null` to clear them.
Calling `useDefaults` again replaces the previous values, including values registered with `URL::defaults` during the current request. Pass `null` to clear them.

Before making a temporary change, you may save the current values using the `getDefaultParameterOverrides` method and pass them back to `useDefaults` when your work finishes. This method only returns the defaults set for the current request, job, or command.

Defaults registered with `URL::defaults` are applied first, followed by resolved defaults and values set with `useDefaults`. Parameters passed directly to the `route` helper always take priority. During a request, `URL::defaults` merges values for that request. Outside a request, it changes the defaults for the entire worker, so jobs and commands should use `useDefaults` for temporary values.
During a request, `URL::defaults` merges values for that request. Outside a request, it changes the defaults for the entire worker, so jobs and commands should use `useDefaults` for temporary values. Worker defaults are applied first, followed by resolved defaults, and then values set with `useDefaults` or with `URL::defaults` during a request. Parameters passed directly to the `route` helper always take priority.

<a name="url-defaults-middleware-priority"></a>
#### URL Defaults and Middleware Priority
Expand Down
24 changes: 24 additions & 0 deletions src/routing/src/UrlGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,14 @@ public function useDefaults(?array $defaults): void
CoroutineContext::set(self::DEFAULT_PARAMETERS_CONTEXT_KEY, $defaults);
}

/**
* Get the default route parameter overrides for the current coroutine.
*/
public function getDefaultParameterOverrides(): array
{
return CoroutineContext::get(self::DEFAULT_PARAMETERS_CONTEXT_KEY, []);
}

/**
* Force the scheme for URLs.
*
Expand Down Expand Up @@ -784,6 +792,14 @@ public function useOrigin(?string $root): void
}
}

/**
* Get the URL origin override for the current coroutine.
*/
public function getForcedOrigin(): ?string
{
return CoroutineContext::get(self::FORCED_ROOT_CONTEXT_KEY);
}

// Laravel's deprecated forceRootUrl() alias is intentionally not ported.
// Use the request-isolated useOrigin() method instead.

Expand All @@ -802,6 +818,14 @@ public function useAssetOrigin(?string $root): void
}
}

/**
* Get the asset URL origin override for the current coroutine.
*/
public function getForcedAssetOrigin(): ?string
{
return CoroutineContext::get(self::FORCED_ASSET_ROOT_CONTEXT_KEY);
}

/**
* Set a callback to be used to format the host of generated URLs.
*
Expand Down
3 changes: 3 additions & 0 deletions src/support/src/Facades/URL.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
* @method static string formatRoot(string $scheme, string|null $root = null)
* @method static string formatScheme(bool|null $secure = null)
* @method static string full()
* @method static array getDefaultParameterOverrides()
* @method static array getDefaultParameters()
* @method static string|null getForcedAssetOrigin()
* @method static string|null getForcedOrigin()
* @method static string|null getForcedScheme()
* @method static \Hypervel\Http\Request getRequest()
* @method static string|null getRootControllerNamespace()
Expand Down
2 changes: 1 addition & 1 deletion src/validation/src/ClosureValidationRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class ClosureValidationRule implements RuleContract, ValidatorAwareRule
* @param Closure $callback the callback that validates the attribute
*/
public function __construct(
protected Closure $callback
public Closure $callback
) {
}

Expand Down
107 changes: 107 additions & 0 deletions tests/Routing/RoutingUrlGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Hypervel\Tests\Routing\RoutingTestCase;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
use Symfony\Component\Routing\Exception\RouteNotFoundException;

Expand Down Expand Up @@ -780,6 +781,33 @@ public function testSetAssetUrl(): void
$this->assertSame('https://assets.example.com/foo/bar', $url->asset('foo/bar'));
}

#[TestWith([null, null, 'https://assets.example.com/app.js'])]
#[TestWith(['', '', 'https://request.example.com/app.js'])]
#[TestWith(['https://tenant-assets.example.com/', 'https://tenant-assets.example.com', 'https://tenant-assets.example.com/app.js'])]
public function testForcedAssetOriginCanBeRestored(?string $origin, ?string $expectedOrigin, string $expectedUrl): void
{
$url = new UrlGenerator(
new RouteCollection,
Request::create('https://request.example.com/'),
'https://assets.example.com'
);

$this->assertNull($url->getForcedAssetOrigin());

$url->useAssetOrigin($origin);
$previous = $url->getForcedAssetOrigin();

$this->assertSame($expectedOrigin, $previous);

$url->useAssetOrigin('https://temporary.example.com');
$this->assertSame('https://temporary.example.com/app.js', $url->asset('app.js'));

$url->useAssetOrigin($previous);

$this->assertSame($expectedOrigin, $url->getForcedAssetOrigin());
$this->assertSame($expectedUrl, $url->asset('app.js'));
}

// REMOVED: forceRootUrl() is a deprecated alias; useOrigin() is the
// request-isolated URL-origin API.
public function testUseOrigin(): void
Expand Down Expand Up @@ -812,6 +840,42 @@ public function testUseOrigin(): void
$this->assertSame('https://www.bar.com/foo', $url->route('plain'));
}

#[TestWith([null, null, 'https://second.example.com/path'])]
#[TestWith(['', '', 'https://second.example.com/path'])]
#[TestWith(['https://tenant.example.com/base/', 'https://tenant.example.com/base', 'https://tenant.example.com/base/path'])]
public function testForcedOriginCanBeRestored(?string $origin, ?string $expectedOrigin, string $expectedUrl): void
{
$url = new UrlGenerator(
new RouteCollection,
Request::create('https://request.example.com/')
);

$resolvedOrigin = 'https://first.example.com';
$resolverCalls = 0;
$url->resolveOriginUsing(function () use (&$resolvedOrigin, &$resolverCalls): string {
++$resolverCalls;

return $resolvedOrigin;
});

$this->assertNull($url->getForcedOrigin());

$url->useOrigin($origin);
$previous = $url->getForcedOrigin();

$this->assertSame($expectedOrigin, $previous);
$this->assertSame(0, $resolverCalls);

$url->useOrigin('https://temporary.example.com');
$this->assertSame('https://temporary.example.com/path', $url->to('path'));

$url->useOrigin($previous);
$resolvedOrigin = 'https://second.example.com';

$this->assertSame($expectedOrigin, $url->getForcedOrigin());
$this->assertSame($expectedUrl, $url->to('path'));
}

public function testOriginResolverIsEvaluatedForEachEligibleUrl(): void
{
$url = new UrlGenerator(
Expand Down Expand Up @@ -1798,6 +1862,49 @@ public function testUseDefaultsReplacesAndClearsCoroutineDefaults(): void
$this->assertSame([], $url->getDefaultParameters());
}

public function testDefaultParameterOverridesCanBeRestored(): void
{
$url = new UrlGenerator(
new RouteCollection,
Request::create('https://request.example.com/')
);

$url->defaults(['locale' => 'en', 'worker' => 'worker']);
$locale = 'fr';
$url->resolveDefaultsUsing(function () use (&$locale): array {
return ['locale' => $locale];
});

$previous = $url->getDefaultParameterOverrides();

$this->assertSame([], $previous);

$url->useDefaults(['locale' => 'it']);
$this->assertSame(['locale' => 'it'], $url->getDefaultParameterOverrides());

$url->useDefaults($previous);
$locale = 'de';

$this->assertSame([], $url->getDefaultParameterOverrides());
$this->assertSame(['locale' => 'de', 'worker' => 'worker'], $url->getDefaultParameters());

RequestContext::set(Request::create('https://request.example.com/'));
$url->defaults(['locale' => 'nl']);
$previous = $url->getDefaultParameterOverrides();

$this->assertSame(['locale' => 'nl'], $previous);

$url->useDefaults(['locale' => 'it']);
$url->useDefaults($previous);

$this->assertSame(['locale' => 'nl', 'worker' => 'worker'], $url->getDefaultParameters());

$url->useDefaults(null);

$this->assertSame([], $url->getDefaultParameterOverrides());
$this->assertSame(['locale' => 'de', 'worker' => 'worker'], $url->getDefaultParameters());
}

public function testUseDefaultsIsIsolatedAcrossNonHttpCoroutines(): void
{
$url = new UrlGenerator(
Expand Down
9 changes: 9 additions & 0 deletions tests/Validation/ValidationRuleParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ public function testEmptyConditionalRulesArePreserved(): void
], $rules);
}

public function testExplodedClosureRulesExposeTheirCallback(): void
{
$callback = static function (): void {};

$exploded = (new ValidationRuleParser([]))->explode(['name' => [$callback]]);

$this->assertSame($callback, $exploded->rules['name'][0]->callback);
}

public function testExplodeFailsParsingSingleRegexRuleContainingPipe(): void
{
$data = ['items' => [['type' => 'foo']]];
Expand Down
Loading