> For the complete documentation index, see [llms.txt](https://playground.e107sk.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://playground.e107sk.com/plugin-dev-quick-notes/how-to/keeping-a-custom-news-url-shape-in-e107.md).

# Keeping a custom news URL shape in e107

How to make e107 produce and understand a news URL shape that is not one of the four built-in ones — without redirects, and without editing core files.

The worked example is the shape

```
/news/view-146-my-article-slug
```

instead of e107's built-in

```
/news/view/146/my-article-slug
```

but the method is the same for any shape you can express as a rule.

***

### When you need this

The usual reason is a site migration. Your old URLs are indexed by search engines, and the new codebase produces a different shape. You have two options:

|                                    | 301 redirects           | Custom URL profile |
| ---------------------------------- | ----------------------- | ------------------ |
| Old URLs keep working              | yes, via a redirect hop | yes, natively      |
| Links on the page use              | the new shape           | the old shape      |
| `<link rel="canonical">` points to | the new shape           | the old shape      |
| Search engines must                | reindex everything      | do nothing         |
| Ongoing maintenance                | rewrite rules to keep   | none               |

Redirects are fine when you *want* to move to the new shape. When you want to **keep** the old shape, a custom URL profile is the better answer: e107 then treats your shape as the real one, and the question of redirects disappears.

***

### How e107 URL profiles work

A URL profile is a PHP class describing one URL shape for one module. It does both directions:

* **parsing** — an incoming request is matched against the profile's rules and turned into a route plus variables;
* **creating** — `e107::url('news', 'view', $row)` runs the same rules backwards and emits a URL in that shape.

Because one profile does both, you never end up with pages that *link* one way and *resolve* another.

The built-in news profiles live in `e107_core/url/news/`:

| File               | Shape                            |
| ------------------ | -------------------------------- |
| `url.php`          | `news.php?extend.1` (no SEF)     |
| `sef_full_url.php` | `/news/news-category/news-title` |
| `sef_noid_url.php` | `/news/news-title`               |
| `sef_url.php`      | `/news/view/1/news-title`        |

Do not edit these. The next e107 update overwrites them.

#### The override directory

e107 provides `e107_core/override/url/` for exactly this. A profile placed at

```
e107_core/override/url/news/sef_url.php
```

takes precedence over `e107_core/url/news/sef_url.php`. The override directory is not part of the upstream distribution, so updates leave it alone.

Two naming rules, both mandatory:

1. **The file name must match a core profile file name.** Here, `sef_url.php`. A new name such as `hyphen_url.php` will not be picked up.
2. **The class name is derived from the location.** For an override of the news module it is `override_news_sef_url`, and it extends `eUrlConfig`.

Your profile does not have to resemble the core file it shadows — only the file name matters. The core profile you shadow becomes unavailable while the override exists, so shadow the one you are least likely to want.

***

### Writing the profile

A profile class has two methods: `config()` returns the configuration and the rules, `admin()` returns what the admin screen shows.

#### The rule syntax

A rule maps a URL pattern to a route:

```php
'view-<id:{number}>-<name:{sefsecure}>' => array('view/item',
    'allowVars'   => array('page'),
    'mapVars'     => array('news_id' => 'id', 'news_sef' => 'name'),
    'legacyQuery' => 'extend.{id}',
),
```

* `<id:{number}>` — a named placeholder and its pattern. `{number}` matches digits, `{sefsecure}` matches a slug, `{secure}` matches a general string.
* `'view/item'` — the route this rule maps to.
* `allowVars` — query variables allowed to survive, e.g. `?page=2`.
* `mapVars` — translates database column names to placeholder names, so `e107::url('news', 'view', $newsRow)` can fill the pattern straight from a news row.
* `legacyQuery` — what the legacy entry point (`news.php`) is called with. `extend.{id}` becomes `news.php?extend.146`.

**Order matters.** The first matching rule wins, both when parsing and when creating. Put the most specific rules first.

#### Separators and ambiguity

The interesting part of the hyphen shape is that the separator also occurs inside the slug: `view-146-my-article-slug`. This works because the placeholder patterns are anchored — `{number}` matches digits only, so the boundary between `146` and the slug is unambiguous. `view-146-2024-annual-report` parses as `id=146`, `name=2024-annual-report`, not as `id=1462024`.

Do not rely on this in general. If you design a shape where two adjacent placeholders can both match the same characters, the split becomes arbitrary. Keep at least one anchored placeholder, or a separator that cannot appear in the values.

#### Empty-value fallbacks

If a news item has no slug, a rule expecting one cannot match. Declare a shorter rule after it:

```php
'view-<id:{number}>-<name:{sefsecure}>' => array('view/item', /* ... */),
'view-<id:{number}>'                    => array('view/item', /* ... */),
```

With `'matchValue' => 'empty'` in the config block, a rule whose placeholder value is empty is skipped when creating a URL, and the next rule is tried.

#### Covering every route

A profile must cover **every** route the module emits, not just the one you care about. Miss one and those links break. For news that is: index, all, item view, category list, category brief, day archive, month archive, tag and author. Take the core profile you are shadowing as the checklist.

#### The admin block

```php
public function admin()
{
    return array(
        'labels' => array(
            'name'        => defined('LAN_EURL_CORE_NEWS') ? LAN_EURL_CORE_NEWS : 'News',
            'label'       => 'Friendly URLs with ID, hyphenated',
            'description' => 'Shape: /news/view-{id}-{slug}',
            'examples'    => array(
                '{SITEURL}news/view-1-news-title',
                '{SITEURL}news/category-1-news-category',
            ),
        ),
        'generate'  => array('table' => 'news', 'primary' => 'news_id',
                             'input' => 'news_title', 'output' => 'news_sef'),
        'form'      => array(),
        'callbacks' => array(),
    );
}
```

`label` and `description` are what you see in the admin dropdown — make them distinguishable from the core profile you are shadowing, or you will not be able to tell which one is selected.

`generate` drives the **Rebuild** button, which regenerates slugs from titles. On a migrated site this is dangerous — see below.

***

### Installing it

1. Create `e107_core/override/url/news/` and put the profile file in it.
2. Go to **Admin → Settings → URLs**.
3. Select your profile in the News row.
4. Press **Update**.
5. Confirm that `e107_system/cache/url/config.php` has been regenerated: the `news` section should name your file in `configPath` and your class in `configClass`, and `rules` should hold your rule set.

If you edit the profile afterwards, delete `e107_system/cache/url/config.php`. The compiled rule set is cached, and an edited profile that "does nothing" is almost always a stale cache.

#### If your profile does not appear in the dropdown

At the time of writing, an override for a module whose name is also a plugin folder — which includes **news** — is discarded before it reaches the admin screen. See Known issue below.

***

### Testing

Test each route in both directions: open the URL (parsing) and check a link on a page that points to it (creating). Parsing alone is not enough — a profile can resolve a URL correctly and still emit a different shape in links.

* item, with and without a slug
* item whose slug starts with digits — confirms the separator logic
* item with a non-ASCII slug
* category list, category brief
* day and month archives
* tag and author lists
* second page of a paginated list (`?page=2`)
* the site's news index

Then view the page source of an article and check `<link rel="canonical">` carries your shape, not the built-in one.

#### Trailing slashes

e107 strips the trailing slash from every URL it generates. If your indexed URLs have one, they still resolve — matching tolerates it — but new links and the canonical tag will not have it. Search engines consolidate on the canonical form over time. Nothing breaks; expect the shape in search results to change eventually.

***

### Migrating an existing site

The profile handles URL *shape*. It does not handle the values inside them.

**The slugs must survive the migration.** Your URLs embed `news_sef` and `category_sef` values. If those columns do not come across intact, the profile will happily generate correctly-shaped URLs containing different slugs, and every indexed URL becomes wrong. Migrate those columns 1:1 and verify a few rows by hand before switching the profile on.

**Do not press "Rebuild".** The Rebuild button in URL configuration regenerates slugs from titles. On a site whose URLs are already indexed this silently rewrites them. If a title has been edited since publication — or the slug was ever adjusted by hand — the regenerated slug differs from the indexed one.

With an ID in the URL nothing 404s, because the ID is what the lookup uses. That is the trap: the site looks fine while the canonical tag now points somewhere other than the indexed URL. Leave Rebuild alone unless you are deliberately regenerating slugs and prepared to redirect.

***

### Known issue: override not listed for news

`eRouter::adminReadModules()` scans the override directory, then runs a clean-up pass meant to remove overrides belonging to plugins that are not installed:

```php
if(in_array($l, $plugins) && !in_array($l, $ret['plugin']))
{
    unset($ret['override'][$i]);
}
```

`$plugins` is the directory listing of `e107_plugins/`. Because `news` is both a core URL module and a bundled plugin folder, it matches — and it can never be in `$ret['plugin']`, because the plugin loop skips anything already registered as a core module. The news override is therefore removed from the list, and the admin dropdown never offers it.

The **runtime is not affected**. `eRouter::buildGlobalConfig()` reads the preference `url_config['news']` directly, and any readable value containing a slash is preserved. Only the admin screen cannot offer the choice.

#### Working around it

Set the preference directly, from a script run as the main admin:

```php
<?php
require_once('class2.php');
if (!getperms('0')) { exit('admin only'); }

$config    = e107::getConfig();
$urlConfig = $config->get('url_config');
if (!is_array($urlConfig)) { $urlConfig = array(); }

$urlConfig['news'] = 'override/sef';

$config->set('url_config', $urlConfig)->save(false, true, false);
$config->setPref('e_url_list/news', 'news')->save(false, true, false);

eRouter::clearCache();
e107::getCache()->clearAll('content');

echo 'done';
```

`override/sef` is the location value for `e107_core/override/url/news/sef_url.php`. Setting `e_url_list` as well mirrors what a normal admin save does.

Delete the script afterwards. It grants a settings change to whoever can reach the URL, and the permission check is the only thing standing in the way.

**After running it, do not press Update on Admin → Settings → URLs.** That form submits whatever the dropdown shows, and the dropdown cannot show your profile — so saving that page silently reverts the preference. If this is a site you maintain long-term, put the snippet behind a button in a small admin plugin, so restoring it is one click rather than a file upload.

Verify by opening `e107_system/cache/url/config.php`: the `news` section must name your override file, and `rules` must not be empty.

***

### Checklist

* [ ] Profile at `e107_core/override/url/news/sef_url.php`, class `override_news_sef_url extends eUrlConfig`
* [ ] Every route the module emits is covered by a rule
* [ ] Fallback rules for empty slugs, declared after the full ones
* [ ] `admin()` label distinguishable from the core profile it shadows
* [ ] `news_sef` / `category_sef` migrated 1:1 and spot-checked
* [ ] Profile selected — via the dropdown, or the preference set directly
* [ ] `e107_system/cache/url/config.php` regenerated and naming your profile
* [ ] All routes tested, parsing *and* link generation
* [ ] Canonical tag carries your shape
* [ ] Rebuild not pressed
