---
topic: redirect
title: Creating, editing and purging redirects
summary: tredirect via RedirectController — why a create rewrites rows you did not name, why an edit destroys the hit counter, and what is not catalogued.
appliesTo:
  - redirect.entry.create
  - redirect.entry.update
  - redirect.unassigned.deleteAll
derivedFrom:
  - includes/src/Router/Controller/Backend/RedirectController.php:60-111
  - includes/src/Router/Controller/Backend/RedirectController.php:188-237
  - includes/src/Redirect/Services/RedirectService.php:79-116
  - includes/src/Redirect/Services/RedirectService.php:118-199
  - includes/src/Redirect/Services/RedirectService.php:207-236
  - includes/src/Redirect/Services/RedirectService.php:287-292
  - includes/src/Redirect/Repositories/RedirectRepository.php:41-50
  - includes/src/Redirect/DomainObjects/RedirectDomainObject.php:13-25
  - includes/src/Redirect/Helpers/Normalizer.php:11-24
  - includes/src/Redirect/Type.php:9-13
  - admin/templates/bootstrap/redirect.tpl:183-225
---

## Domain model

Two tables. `tredirect` (PK `kRedirect`) holds `cFromUrl`, `cToUrl`, `nCount` (how often the redirect
fired), `cAvailable` (`u` unknown / `y` reachable / `n` unreachable), `paramHandling`, `type` and
`dateCreated`. `tredirectreferer` (PK `kRedirectReferer`) records who followed one, via a plain
`kRedirect` column — **there is no foreign key and no cascade**, so a referer row can outlive its
redirect.

`type` is one of `Redirect/Type.php:9-13`: `0` Unknown, `1` Wawi sync, `2` Import, `3` Manual, `4` Not
found. A row with an EMPTY `cToUrl` is not a redirect — it is the shop's own 404 log, and the target
of `redirect.unassigned.deleteAll`.

Everything runs through one controller and one dispatch key: `Request::verifyGPDataString('action')`
(`RedirectController.php:60-62`), inside `Form::validateToken()` (`:73-75`). A token failure `return`s
**silently** — no alert, no exception, a rendered 200. The invoker mints the token itself
(`BackendActionInvoker.php:99-107`), so you cannot hit that through `invoke_admin_action`. Feedback is
`alertService` only (`:198`, `:200`, `:218`, `:233`, `:236`) — session-scoped, absent from the response
body. Between the two, `verify.ok` is the only reliable signal that anything happened, which is why
every entry here carries a `verify:` clause instead of trusting `ok`.

## Reads first

1. `run_sql_read "SELECT kRedirect, cFromUrl, cToUrl, paramHandling, nCount, type, cAvailable FROM
   tredirect ORDER BY kRedirect"` — the whole table, and the source of `redirect-id`.
2. Before a create: `run_sql_read "SELECT kRedirect, cFromUrl, cToUrl FROM tredirect WHERE cToUrl =
   '<new source>' OR cFromUrl = '<new source>'"` — the rows the create will rewrite or delete for you.
3. Before a purge: `run_sql_read "SELECT COUNT(*) AS n FROM tredirect WHERE cToUrl = ''"`.

## Walkthrough — creating a redirect

`action=new` with no `redirect-id` reaches `actionCreate()` (`:188-205`) and then
`RedirectService::save()` (`:79-116`). Four things happen before the insert, and three of them are
surprising.

1. **Both URLs are normalised** (`:81-82`, `Normalizer.php:11-24`): a leading `/` is forced and
   surrounding slashes are trimmed. What you send is not what is stored unless you already sent the
   canonical `/path` form. This is why the entry's `verify:` is a `rowCount` on the destination
   rather than an equality against your raw argument.
2. **The destination is fetched over HTTP.** `validateData()` (`:195-197`) calls
   `checkAvailability()` → `get_headers()` (`:207-236`) and demands an HTTP 2xx. Anything else —
   404, timeout, a name that does not resolve — throws `UnreachableUrlException` and **nothing at all
   is written**. The controller catches it, adds an error alert (`:199-204`) and still answers 200.
   This is the `http` side effect on the entry, and it is why a dry run of the create is refused: a
   rollback cannot un-send a request.
3. **The write is not scoped to one row.** `updateOld()` (`:88`, `:118-130`) walks every existing row
   whose `cToUrl` equals your new SOURCE and either deletes it (when it would become a self-redirect)
   or rewrites its destination to your new one. `isDeadlock()` (`:90-92`, `:158-165`) removes an
   opposing pair outright. `updateCircular()` (`:93`, `:132-156`) recurses back into `save()` and
   **swallows its own failures into a debug log** (`:147-155`) — a partial chain rewrite reports
   success. You cannot know the affected set before the call, which is why the journal scope is the
   whole table and why the entry is `Destructive` rather than `Reversible`.
4. Only then does `save()` decide between an insert (`:96`) and a `batchUpdate` of an existing row
   with an empty destination (`:102-113`). Because either branch can run, **no `idResolution` is
   declared** — neither `lastInsertId` nor `maxIdDelta` is guaranteed to name a new row.

A source that already exists with a non-empty destination throws `RedirectAlreadyExistsException`
(`:115`) — an alert and a 200.

## Walkthrough — editing a redirect

`action=new` **plus** `redirect-id > 0` takes the update branch (`createOrUpdate()`, `:103-111`) —
same dispatch value, different behaviour, hence two catalog entries.

`actionUpdate()` (`:207-222`) builds a brand-new `RedirectDomainObject` from exactly four values:
source, destination, id, `paramHandling`. `AbstractDBRepository::update()` (`:153-174`) then writes
the WHOLE object. The three columns the controller never set therefore land as their **constructor
defaults** (`RedirectDomainObject.php:18-21`):

| column | becomes | meaning |
|---|---|---|
| `nCount` | `0` | the hit counter is destroyed |
| `cAvailable` | `'u'` | reachability forgotten |
| `type` | `0` | a MANUAL / Wawi / Import redirect is demoted to Unknown |

Fixing a typo in a destination therefore loses that redirect's provenance and statistics, and the
screen offers no way around it; the entry's second `verify:` clause asserts the wipe rather than
pretending it did not happen.

The update path also **skips the validation the create path runs**: `validateData($updateDO, false,
false)` (`:289`) — no reachability probe, no `EmptyUrlException`, no normalisation, no duplicate
check. Raw equality on `cToUrl` is exact here, and two rows can legitimately share a `cFromUrl`.

Omitting `paramHandling` does not leave it alone: `Request::pInt` (`:213`) reads an absent key as `0`.
Resend every field you want to keep.

## Walkthrough — purging unassigned redirects

`action=delete_all` → `RedirectRepository::deleteUnassigned()` (`:41-50`), ONE statement:

```sql
DELETE tredirect, tredirectreferer
  FROM tredirect
  LEFT JOIN tredirectreferer ON tredirect.kRedirect = tredirectreferer.kRedirect
 WHERE tredirect.cToUrl = ''
```

It is unbounded — count the rows first. It is also the only path that removes referer rows:
`deleteByID()` (`RedirectService.php:44-47`) touches `tredirect` alone and **orphans** every
`tredirectreferer` row pointing at it.

The entry has no fields, so the only clause that can be written is a literal:
`SELECT COUNT(*) AS n FROM tredirect WHERE cToUrl = ''` expecting `0`. That proves the table is
clean; it cannot prove this call is what cleaned it. Do read 3 before and after.

## What is deliberately not here

**`action=save` and `action=delete` were DROPPED, not deferred.** Both read a nested POST array —
`$_POST['redirects'][{kRedirect}][cToUrl]` (`actionSave()`, `:154-176`, template `redirect.tpl:225`)
and `[enabled]` (`actionDelete()`, `:224-237`, template `:183`). A catalog field name may carry a
trailing `[]` and nothing else (`ActionCatalogValidator.php:421-426`), so those fields have no legal
spelling and an entry for them would ship a name that can never bind. Bulk edit and multi-select
delete are therefore not reachable through MCP at all — use `redirect.entry.update` one row at a
time. There is no single-row delete either.

**`redirectCheckAvailability` is NOT catalogued, and that is a finding.** It is registered on the IO
endpoint with **no permission argument** (`Router/Controller/Backend/IOController.php:121`) and makes
an outbound HTTP request to whatever URL it is handed. Report that upstream rather than wrapping it.

**`action=edit` is unusable**: it reads `Request::gInt('id')` (`:180`) while the invoker seeds `$_GET`
with `token` only (`BackendActionInvoker.php:107`), and it only assigns Smarty variables the
non-rendering backend discards. Use read 1. **`csvImport` / `csvExport` are out of scope** — the
importer needs an uploaded file, the exporter streams to stdout (`:113-139`).

## Four failure modes to expect

1. **"It reported success and the row is not there."** The create's reachability probe failed; the
   alert is session-only, so `verify.ok` is the only place it shows. On a shop whose own hostname does
   not resolve from the server (a tunnelled or air-gapped dev shop), **every** create fails this way.

2. **"It created the row and mangled three others."** `updateOld()` / `updateCircular()`. Run read 2
   first; the chain rewriting cannot be switched off.

3. **"The edit worked and the statistics are gone."** By design, per the table above. Capture `nCount`
   and `type` before the call if they matter — only the journal restores them.

4. **"I cannot address the row I want in a clause."** `redirect-id` contains a hyphen; both the
   placeholder grammar (`Placeholder.php:19-21`) and the `:name` scan in a `verify:` query
   (`ActionInvocation.php:716`) accept `[A-Za-z_][A-Za-z0-9_]*` only, so `{{args.redirect-id}}` and
   `:redirect-id` are illegal. The update entry verifies and journals by `cFromUrl`/`cToUrl` instead,
   scoped to the whole table. Passing `redirect-id` as an argument works fine.
