Prevent creating absolute URLs

Hello lovely people,
I need a (temporary) way to remove both hostname and protocol from all absolute URLs generated by Kirby in the output and replace them with only absolute paths, ie:

to:   /top/monthly

I ran into this issue during USB debugging a site on my phone where none of the styles, script or images where rendered if Kirby generated the URL, either from Markdown tags, css() or js() calls. The webserver uses a different machine name in the LAN so hostnames entered in the browser on the phone ( and desktop ( differ. Apache is setup to listen to both.

URLs from Kirby will always contain the fully qualified server name from Apache ($_SERVER['SERVER_NAME']) which might not be the same as the requested host name entered in the browser. That’s usually fine, but I was wondering if there’s a way to prevent this behaviour.

For the time being I can reconfigure Apache to use the machine’s LAN hostname, but this means I can only ever test 1 site at a time over the LAN – unless I can also figure out a ways to configure my router…

I presume there’s some “hook” I can use meanwhile to make a string replacement over the whole HTML generated – or on a more granual level hook into the markup generated for image, css, js tags.

Thanks for hints & tips & stay healthy!

You can use the Url component to modify your paths:

Thank you.
I eventually made it work but here are some issues I ran into:

I tried the given example code as is, but it instantly bails with:

TypeError: Argument 3 passed to Kirby\Cms\App::{closure}() 
  must be of the type array, null given, called in 
  W:\MPP\\www\kirby\src\Cms\Url.php on line 94 
  in file W:\MPP\\www\site\plugins\urls\index.php on line 19
Stack trace:
  1. TypeError->() W:\MPP\\www\site\plugins\urls\index.php:19
  2. Kirby\Cms\App->{closure}() W:\MPP\\www\kirby\src\Cms\Url.php:94
  3. Kirby\Cms\Url->to() W:\MPP\\www\kirby\config\helpers.php:114
  4. css() W:\MPP\\www\kirby\config\helpers.php:95
  5. {closure}() [internal]:0
  6. array_map() W:\MPP\\www\kirby\config\helpers.php:96
  7. css() W:\MPP\\www\site\snippets\header.php:32

The stack trace in header.php leads to:
css(['assets/css/base.css'], ['media'=>'only screen'])

If I debug the code, $option is indeed always NULL (for css() and js()) but the signature of the example asks for array $options = []
The Parameters table however says $options array|null.
I changed the signature for my plugin, but the example should probably be revised.

However, the plugin/component apparently does not “prerun” for files located in the content folder that become “/media/” URLs
$page->files()->filterBy('extension', 'css')->pluck('url')

Worse yet it is not called for or any url() that comes from a Page or File object:

$page->prevListed()->url()  and alike
$page->related()->toPages() => $related->url()

These URLs already have the fully qualified server path applied in their “url” property.

Not a show stopper 'cos I “only” need to rewrite my asset URLs (now), but an inconvenient inconsistency - unless there is another thingy to tackle this within Page/File objects.
The statement “Modify all URLs” from the API page seems not quiet accurate :wink:

Stay healthy!

I you only have to modify asset URLs, you can also use the


I think when using the Url component, you are better of with removing the type hints

function ($kirby, $path, $options, $original)

yep, changing the signature is what I eventually did, yet the example code should be updated to reflect this 'cos it’ll throw.

The URL component works fine for css() and js(), it does not handle URLs from pages or files at all, so it’s of no use if one has to mangle these URLs for whatever reason.

I also noticed that within the code behind ->url() it apparently uses $_SERVER['SERVER_NAME'] to build/insert the host name and not $_SERVER['HTTP_HOST'], which would’ve actually solved my issue in the first place.
If I pass $page->files()->filterBy(‘extension’, ‘css’)->pluck(‘url’)`` to css() the “host” always reflects SERVER_NAME and not the HTTP_HOST entered in the browser.

I leave you with that :slight_smile:


It also works for pages, but the problem is that $path returns an absolute URL for pages, so you have to use conditions here to handle each case.

But when I tested it it still returned errors for special URLs, so there are either still bugs or I did something wrong.

Will definitely modify the signature.

Well, my plugin isn’t called for anything such as $page->url().
It only auto-executes for css() and js().

Dealing with different kind of URLs in my plugin isn’t the issue, but if the plugin doesn’t get called, there’s not much I can do :wink:

But as I wrote: that’s not an issue now, as I only need this for styles and scripts and it does the job.

It’d also be nice if the “Array of options for the Uri class” would be documented somewhere. There’s also no notion of their structure in Url::to()

Which version have you used for testing?

Because this code

Kirby::plugin('my/urls', [
    'components' => [
      'url' => function ($kirby, $path, $options, $original): string {    
          return Url::path($path, true); 

also changes page URLs in 3.3.5…

(Just a quick and dirty test and doesn’t work with HTML::a() it seems.)

Guten Morgen :slight_smile:

Which version have you used for testing?

It’s with Kirby 3.3.5 according to composer.

There aren’t any errors or exceptions popping up, it just won’t run for any of the following statements: [edit: removed html markup]


They’re present in a snippet(“header”), but code within the template scope doesn’t trigger either.
I checked this via breakpoints in PhpStorm: all direct $page objects and derivatives incl. images() already have a [url] property set incl. the full server URL, and the same goes for (image:) tags from the content: The plugin does NOT execute for any of these.

And I presumed that at the files()->filterBy() “level” of this, it should run too, but don’t. (also in header.php snippet)

$page->files()->filterBy('extension', 'css')->pluck('url')
$page->files()->filterBy('extension', 'js')->pluck('url')

I pass those as arguments to css() and js(), the plugin then triggers, and will remove the host after the fact.

And that’s the plugin code (site/plugins/webmechanic/index.php)

use Kirby\Cms\App as Kirby;

Kirby::plugin('webmechanic/azentro', [
  'components' => [
    'url' => function (Kirby $kirby, string $path, $options = [], Closure $originalHandler): string {
      if (is_null($options)) $options = [];
      // rewrite URLs to use the HTTP_HOST instead of SERVER_NAME
      $parts         = parse_url($path) + parse_url('http://' . $_SERVER['HTTP_HOST'] .'/');
      $parts['path'] = '/'. ltrim($parts['path'], '/');
      $parts['host'] = str_replace($_SERVER['SERVER_NAME'], $_SERVER['HTTP_HOST'], $parts['host']);
      $url           = '';
      // reassemble with what we care for
      foreach (['scheme'=>'%s://', 'host'=>'%s', 'port'=>':%d', 'path'=>'%s', 'query'=>'?%s', 'fragment'=>'#%s'] as $part => $glue) {
        if (isset($parts[$part])) {
          $url .= sprintf($glue, $parts[$part]);
      return $url;

I thought you wanted to use only the path without the domain… And since I can’t re-create your setup, I can’t test why it doesn’t work.

Anyway, the URL component doesn’t work for files, that’s what the file::url() and file::version() components are for.

Maybe you can alternatively set the URL in your config and use domain specific config files?

I thought you wanted to use only the path without the domain…

That was indeed the initial plan before I learned about the url component. Using only the path would make any URL work under any hostname, but since Kirby spits out the hostname, I ran into the problem I posted above.

I then decided to simply replace SERVER_NAME with HTTP_HOST once I noticed that I get URLs with and without a hostname. That’s the code I posted above and it does the trick for the USB remote debugging scenario I’m currently facing.

Not sure (and don’t care to dig into this now) if domain specific config files would actually work here. If Kirby (also) uses SERVER_NAME to determine a host’s config file, it’ll fail to load one (based on HTTP_HOST) in the first place.

Anyway: the url component does work for what I need now.

Yet I’m still wondering: why does ‘url’ work for ‘css’ and ‘js’ if these two are separate components, but does not for ‘file:url’? And what about $page->url()?

Have a nice day.

For the records and other readers:
It’s not a good idea to declare “handlers” for ‘url’ and ‘css’/‘js’ at least within the same Plugin, as this exceeds the nesting level and PHP dies :wink:
Also beware the lack of the last Closure argument in the css, js and file::url components handlers if you plan to reuse the same (external) function/method for all of them.


And since I can’t re-create your setup, I can’t test why it doesn’t work.

If anyone cares to try this and runs Apache, create/edit a VirtualHost section and add both ServerName and ServerAlias using different names.
Also add both host names to the hosts file of your developer machine.

<VirtualHost> # check IP
  ServerName  # autogenerated by Fritz!Box [1]
  DocumentRoot ...

This setup presumes you do all developemt in your LAN. If you deploy your tests to a public webserver none of this applies to you. Also: none of this will trouble you or makes sense if you have the ability to edit your router’s DNS or use some other network trickery. In which case you’re also unlikely to run into the “mixed hostname” problem in the first place.

The Tests
Make sure the site template makes calls to css()/js() or content contains (image:).
Somewhere in your template add:

HH: <?= $_SERVER['HTTP_HOST'] ?></pre>

Open your website using the ServerName
Things should look fine. Check from the computer running the webserver and some other device in your W/LAN. Both SERVER_NAME and HTTP_HOST refer to the same hostname.

Now open the website using the ServerAlias instead.
Your site should look broken: no CSS applied, JS not running, images missing. Check the generated HTML in your DevTools or viewsource.
Note that CSS and JS links and IMG tags still contain the hostname.
This time SERVER_NAME is still but HTTP_HOST became: The name actually entered into the browser.

Why does all of this matter?
Other than toying with debugging a Kirby site running in Firefox or Chrome on a USB tethered device? Well, if the computer running the webserver is only known by a single name (it’s own hostname) inside the LAN you can’t run/test multiple sites/domains running on that very machine using other devices in your network. The webserver will always respond serving the first/single VirtualHost that matches it’s own hostname and LAN-IP.

That’s of course not a show-stopper for many, but utterly inconvenient if you work on multiple projects simultaniously or a multi-domain project running different backend systems (shop, blog, website, forum).
Also you can’t mimic a “CDN” properly and using DynDNS will probably fail, too.

Then there are circumstances “in the wild” where a (physical) server has different hostnames within its LAN, sitting behind a proxy or firewall. In this case the (public) HTTP_HOST and (local) SERVER_NAME don’t match. As a result any URL generated using the SERVER_NAME only will no longer point to a ressource accessible to the public eye (also a DynDNS scenario).

[1] If you have a Fritz!Box router, every device’s local hostname in your W/LAN will automatically become part of the local domain, i.e “”, ". That’s also the ServerName you have to use in order access your local webserver from all devices in yoru LAN.


I wonder if the easiest solution was setting the url in config like this then:

'url' => 'http://' . $_SERVER['HTTP_HOST']

Given there are always 50 Shades of Doing Things in Kirby, it might be annoyingly easy as that.
I might give it a try.