Manage add/edit form
August 27, 2025 · AI Conversations
—
# 1) Single Page + Controller structure
/application
/single_pages
feed.php <-- one page for both add & edit
/controllers/single_page
feed.php <-- controller
> If this lives in a package, mirror the same structure under `/packages/your_pkg_handle/…`.
—
# 2) Controller (application/controllers/single_page/feed.php)
<?php
namespace ApplicationControllerSinglePage;
use ConcreteCorePageControllerPageController;
use ConcreteCoreHttpResponseFactoryInterface;
use ConcreteCoreUrlResolverManagerResolverManagerInterface;
use ConcreteCoreValidationCSRFToken;
use DoctrineDBALConnection; // simple DB example
use ConcreteCoreSupportFacadeUrl;
class Feed extends PageController
{
protected Connection $db;
protected Token $token;
public function on_start()
{
$this->db = $this->app->make(Connection::class);
$this->token = $this->app->make(Token::class);
}
/**
* Open same page for Add (no id) or Edit (with id).
* /feed -> Add mode
* /feed/123 -> Edit mode (id=123)
* or /feed?id=123 -> also works
*/
public function view($id = null)
{
// Accept id from path or query
$id = $id ?: (int)($this->request->query->get('id') ?? 0);
$feed = null;
if ($id > 0) {
$feed = $this->db->fetchAssociative('SELECT * FROM Feeds WHERE id = ?', [$id]);
if (!$feed) {
$this->flash('error', t('Feed not found.'));
return $this->redirect(Url::to('/feed'));
}
}
$this->set('feed', $feed); // null = Add mode
$this->set('isEdit', (bool)$feed);
$this->set('actionURL', $this->action('save')); // form action
}
/**
* Handle both create + update
*/
public function save()
{
if (!$this->token->validate('feed_save')) {
$this->flash('error', $this->token->getErrorMessage());
return $this->buildRedirectAfterSave();
}
$id = (int)$this->request->request->get('id');
$title = trim((string)$this->request->request->get('title'));
$url = trim((string)$this->request->request->get('url'));
// Basic validation
$errors = [];
if ($title === '') $errors[] = t('Title is required.');
if ($url === '') $errors[] = t('URL is required.');
if ($errors) {
foreach ($errors as $e) $this->flash('error', $e);
return $this->buildRedirectAfterSave($id);
}
if ($id > 0) {
// UPDATE
$exists = $this->db->fetchOne('SELECT COUNT(*) FROM Feeds WHERE id = ?', [$id]);
if (!$exists) {
$this->flash('error', t('Feed not found.'));
return $this->redirect(Url::to('/feed'));
}
$this->db->update('Feeds', ['title' => $title, 'url' => $url], ['id' => $id]);
$this->flash('success', t('Feed updated.'));
return $this->redirect(Url::to('/feed', $id)); // stay on same page in edit mode
} else {
// INSERT
$this->db->insert('Feeds', ['title' => $title, 'url' => $url]);
$newId = (int)$this->db->lastInsertId();
$this->flash('success', t('Feed created.'));
return $this->redirect(Url::to('/feed', $newId)); // jump into edit mode of the new record
}
}
/**
* Optional: delete from same page
*/
public function delete($id = null)
{
$id = (int)$id;
if (!$this->token->validate('feed_delete_'.$id)) {
$this->flash('error', $this->token->getErrorMessage());
return $this->redirect(Url::to('/feed', $id));
}
$this->db->delete('Feeds', ['id' => $id]);
$this->flash('success', t('Feed deleted.'));
return $this->redirect(Url::to('/feed'));
}
private function buildRedirectAfterSave(?int $id = null)
{
return $id ? $this->redirect(Url::to('/feed', $id)) : $this->redirect(Url::to('/feed'));
}
}
**Notes**
– `view($id = null)` receives **path args**, so `/feed/42` passes `42` to `view`.
– You can also support `?id=42` for convenience.
– One `save()` handles both create/update by checking if `id` exists.
– Uses **DBAL** for brevity (`Feeds` table with `id`, `title`, `url`). Swap for Doctrine entities if you prefer.
—
# 3) Single Page view (application/single_pages/feed.php)
<?php
defined('C5_EXECUTE') or die('Access Denied.');
/** @var array|null $feed */
/** @var bool $isEdit */
/** @var string $actionURL */
use ConcreteCoreSupportFacadeUrl;
use ConcreteCoreValidationCSRFToken;
/** @var Token $token */
$token = app(Token::class);
$id = $feed['id'] ?? 0;
$title = $feed['title']?? '';
$url = $feed['url'] ?? '';
?>
<div class="ccm-ui">
<h2><?= $isEdit ? t('Edit Feed') : t('Add Feed'); ?></h2>
<form method="post" action="<?= h($actionURL) ?>">
<?php $token->output('feed_save'); ?>
<input type="hidden" name="id" value="<?= (int)$id ?>">
<div class="form-group">
<label for="title"><?= t('Title') ?></label>
<input type="text" class="form-control" id="title" name="title" value="<?= h($title) ?>" required>
</div>
<div class="form-group">
<label for="url"><?= t('URL') ?></label>
<input type="url" class="form-control" id="url" name="url" value="<?= h($url) ?>" required>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<?= $isEdit ? t('Update') : t('Create') ?>
</button>
<?php if ($isEdit) { ?>
<form method="post" action="<?= Url::to('/feed', 'delete', $id) ?>" class="d-inline">
<?php app(Token::class)->output('feed_delete_'.$id); ?>
<button type="submit" class="btn btn-danger" onclick="return confirm('<?= t('Delete this feed?') ?>')">
<?= t('Delete') ?>
</button>
</form>
<?php } ?>
</div>
</form>
<hr>
<h3 class="mt-4"><?= t('All Feeds') ?></h3>
<table class="table table-striped">
<thead>
<tr>
<th><?= t('ID') ?></th>
<th><?= t('Title') ?></th>
<th><?= t('URL') ?></th>
<th><?= t('Actions') ?></th>
</tr>
</thead>
<tbody>
<?php
$db = app(DoctrineDBALConnection::class);
$rows = $db->fetchAllAssociative('SELECT * FROM Feeds ORDER BY id DESC');
foreach ($rows as $row) { ?>
<tr>
<td><?= (int)$row['id'] ?></td>
<td><?= h($row['title']) ?></td>
<td><a href="<?= h($row['url']) ?>" target="_blank"><?= h($row['url']) ?></a></td>
<td>
<a class="btn btn-sm btn-secondary" href="<?= Url::to('/feed', (int)$row['id']) ?>">
<?= t('Edit on same page') ?>
</a>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
**How it works**
– **Add Mode**: open `/feed` → empty form.
– **Edit Mode**: open `/feed/123` (or `/feed?id=123`) → same page, fields prefilled.
– The “All Feeds” table links to **the same page** with the id, so you stay on feed.php.
—
# 4) Register the Single Page (once)
Programmatically (in your package controller `install()` / `upgrade()`), or via Dashboard > Pages > Single Pages:
use ConcreteCorePageSingle as SinglePage;
public function install()
{
parent::install();
$sp = SinglePage::add('/feed', $this->getPackage());
if ($sp) {
$sp->update(['cName' => t('Feed Manager')]);
}
}
—
# 5) Optional: simple table
For quick testing:
CREATE TABLE Feeds (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
url VARCHAR(1024) NOT NULL
);
—
## TL;DR
– Use **one Single Page** (`/feed`) and pass an **optional ID** to `view($id)`.
– Populate the same form for **add** (no id) or **edit** (with id).
– Handle **save()** for both create/update; redirect back to `/feed/{id}` to remain on the same page.
If you want the Doctrine Entity version instead of raw DBAL, say the word and I’ll swap the sample to entities + repository.
– `/feed/add` → add mode (empty form)
– `/feed/edit/2` → edit feed with ID 2
– (you can still keep `/feed` and `/feed/2` working too)
You’ve got two good ways. Here’s the cleanest:
# Option A — Pretty routes mapped to the same Single Page controller (recommended)
## 1) Register routes (package `on_start()` or `RouteList`)
use ConcreteCoreRoutingRouter;
public function on_start()
{
/** @var Router $router */
$router = $this->app->make(Router::class);
// views
$router->get('/feed/add', 'ApplicationControllerSinglePageFeed::add');
$router->get('/feed/edit/{id}', 'ApplicationControllerSinglePageFeed::edit')
->setRequirements(['id' => 'd+']);
// actions (post)
$router->post('/feed/save', 'ApplicationControllerSinglePageFeed::save');
$router->post('/feed/delete/{id}', 'ApplicationControllerSinglePageFeed::delete')
->setRequirements(['id' => 'd+']);
}
> If you’re not in a package, put the same in `/application/bootstrap/app.php` after getting `$router`.
## 2) Controller (`application/controllers/single_page/feed.php`)
Add two tiny methods that reuse your existing logic:
public function add()
{
// Reuse the same view with "no id" = add mode
return $this->view(null);
}
public function edit($id = null)
{
// Reuse the same view with an id = edit mode
return $this->view((int)$id);
}
Your existing `save()` and `delete()` stay the same.
(They’re now hit via `/feed/save` and `/feed/delete/{id}`.)
## 3) View links (`application/single_pages/feed.php`)
Point buttons/links to the pretty routes:
use ConcreteCoreSupportFacadeUrl;
// "Create new" button:
<a class="btn btn-primary" href="<?= Url::to('/feed/add') ?>"><?= t('Add New Feed') ?></a>
// Each row's edit button:
<a class="btn btn-sm btn-secondary" href="<?= Url::to('/feed/edit', (int)$row['id']) ?>">
<?= t('Edit') ?>
</a>
// Form action (create/update):
<form method="post" action="<?= h(Url::to('/feed/save')) ?>">
// Delete (per row):
<form method="post" action="<?= Url::to('/feed/delete', (int)$row['id']) ?>" class="d-inline">
<?php app(ConcreteCoreValidationCSRFToken::class)->output('feed_delete_'.(int)$row['id']); ?>
<button type="submit" class="btn btn-danger" onclick="return confirm('<?= t('Delete this feed?') ?>')">
<?= t('Delete') ?>
</button>
</form>
That’s it—now `/feed/add` and `/feed/edit/2` render the **same page** (`feed.php`) but in the right mode.
—
# Option B — Use built-in Single Page actions (quick, but URL shows `/-/`)
If you don’t want custom routes, just use:
– `$this->action('add')` → `/feed/-/add`
– `$this->action('edit', $id)` → `/feed/-/edit/2`
And add `add()` / `edit($id)` methods like above. Works fine, just not as “pretty”.
—
If you want both `/feed/2` and `/feed/edit/2` to work, keep your original `view($id)` path behavior and also register the `/feed/edit/{id}` route—both will call the same code.
– `/feed` → add mode
– `/feed/12` → edit 12
– `/feed/add` → add mode (calls `view()`)
– `/feed/edit/12` → edit 12 (calls `view(12)`)
### 1) Routes (map to `view` only)
Put in your package `on_start()` (or `/application/bootstrap/app.php`):
use ConcreteCoreRoutingRouter;
public function on_start()
{
$router = $this->app->make(Router::class);
// Pretty routes that ALL hit Feed::view
$router->get('/feed/add', 'ApplicationControllerSinglePageFeed::view');
$router->get('/feed/edit/{id}', 'ApplicationControllerSinglePageFeed::view')
->setRequirements(['id' => 'd+']);
// Post actions (unchanged)
$router->post('/feed/save', 'ApplicationControllerSinglePageFeed::save');
$router->post('/feed/delete/{id}', 'ApplicationControllerSinglePageFeed::delete')
->setRequirements(['id' => 'd+']);
}
### 2) Controller: detect mode inside `view()`
Update your `view()` to infer **add/edit** by URL path, but still use one method:
public function view($id = null)
{
$path = rtrim((string)$this->request->getPath(), '/');
$isAddPath = (bool)preg_match('#/feed/add$#', $path);
$isEditPath = (bool)preg_match('#/feed/edit/d+$#', $path);
// Accept id from path (/feed/12), pretty edit (/feed/edit/12), or ?id=12
$id = $id ?: (int)($this->request->query->get('id') ?? 0);
$mode = 'add';
if ($isAddPath) {
$mode = 'add';
} elseif ($isEditPath || $id > 0) {
$mode = 'edit';
}
$feed = null;
if ($mode === 'edit') {
if ($id <= 0) {
$this->flash('error', t('Missing feed ID.'));
return $this->redirect(ConcreteCoreSupportFacadeUrl::to('/feed'));
}
$feed = $this->db->fetchAssociative('SELECT * FROM Feeds WHERE id = ?', [$id]);
if (!$feed) {
$this->flash('error', t('Feed not found.'));
return $this->redirect(ConcreteCoreSupportFacadeUrl::to('/feed'));
}
}
$this->set('feed', $feed);
$this->set('mode', $mode); // 'add' or 'edit'
$this->set('actionURL', $this->app->make('url')->to('/feed/save'));
}
### 3) Save redirects (stay with pretty routes; still hits `view()`)
if ($id > 0) {
// after update
return $this->redirect(ConcreteCoreSupportFacadeUrl::to('/feed/edit', $id));
} else {
// after insert
return $this->redirect(ConcreteCoreSupportFacadeUrl::to('/feed/edit', $newId));
}
### 4) View links
// Add new
<a class="btn btn-primary" href="<?= ConcreteCoreSupportFacadeUrl::to('/feed/add') ?>">
<?= t('Add New Feed') ?>
</a>
<!-- Row edit -->
<a class="btn btn-sm btn-secondary" href="<?= ConcreteCoreSupportFacadeUrl::to('/feed/edit', (int)$row['id']) ?>">
<?= t('Edit') ?>
</a>
<!-- Form action -->
<form method="post" action="<?= h(ConcreteCoreSupportFacadeUrl::to('/feed/save')) ?>">
That’s it—**no `add()` or `edit()` methods at all**. All routes land in `view()`, which decides the mode based on the path/id.
1) the Single Page isn’t installed, 2) the controller/namespace/path doesn’t match, or 3) your custom routes aren’t actually registered (or URL rewriting is off).
Here’s a tight checklist to make **/feed**, **/feed/add**, and **/feed/edit/2** all hit **Feed::view()**.
# 1) Single Page exists
– Make sure the page is installed once at **/feed**.
– If in a package (recommended):
use ConcreteCorePageSingle as SinglePage;
public function install()
{
$pkg = parent::install();
if (!ConcreteCoreSupportFacadePage::getByPath('/feed')->getCollectionID()) {
$sp = SinglePage::add('/feed', $pkg);
if ($sp) { $sp->update(['cName' => t('Feed Manager')]); }
}
}
– Or via Dashboard → Pages & Themes → Single Pages → “Add Single Page” → enter `/feed`.
– File locations (application version):
/application/single_pages/feed.php
/application/controllers/single_page/feed.php
Package version:
/packages/<pkg_handle>/single_pages/feed.php
/packages/<pkg_handle>/controllers/single_page/feed.php
# 2) Controller namespace/path match
**Application version**
<?php
namespace ApplicationControllerSinglePage;
use ConcreteCorePageControllerPageController;
class Feed extends PageController
{
public function view($id = null) { /* … */ }
public function save() { /* … */ }
public function delete($id = null) { /* … */ }
}
**Package version**
<?php
namespace ConcretePackage<PkgHandle>ControllerSinglePage;
use ConcreteCorePageControllerPageController;
class Feed extends PageController
{
public function view($id = null) { /* … */ }
// …
}
> The namespace MUST match the file location. If it doesn’t, Concrete won’t find the controller and you’ll see “Page Not Found”.
# 3) Route all pretty URLs to `view()` (not `add`/`edit`)
Register routes in **one place** only.
**Application (no package):** `/application/bootstrap/app.php`
use ConcreteCoreRoutingRouter;
$app->extend(Router::class, function (Router $router) {
// Everything hits Feed::view
$router->get('/feed/add', 'ApplicationControllerSinglePageFeed::view');
$router->get('/feed/edit/{id}', 'ApplicationControllerSinglePageFeed::view')
->setRequirements(['id' => 'd+']);
// Post actions:
$router->post('/feed/save', 'ApplicationControllerSinglePageFeed::save');
$router->post('/feed/delete/{id}', 'ApplicationControllerSinglePageFeed::delete')
->setRequirements(['id' => 'd+']);
return $router;
});
**Package:** in your package controller `on_start()`
use ConcreteCoreRoutingRouter;
public function on_start()
{
$router = $this->app->make(Router::class);
$router->get('/feed/add', 'ConcretePackage<PkgHandle>ControllerSinglePageFeed::view');
$router->get('/feed/edit/{id}', 'ConcretePackage<PkgHandle>ControllerSinglePageFeed::view')
->setRequirements(['id' => 'd+']);
$router->post('/feed/save', 'ConcretePackage<PkgHandle>ControllerSinglePageFeed::save');
$router->post('/feed/delete/{id}', 'ConcretePackage<PkgHandle>ControllerSinglePageFeed::delete')
->setRequirements(['id' => 'd+']);
}
> If your controller is under Application but you register package namespaces (or vice-versa), you’ll get 404.
# 4) Quick sanity checks
– Try direct URL with dispatcher: **/index.php/feed** and **/index.php/feed/add**
– If these work, your issue is URL rewriting → fix `.htaccess` (Pretty URLs) in web root:
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php [L]
Enable in Dashboard → System & Settings → SEO & Statistics → Pretty URLs.
– Clear cache: Dashboard → System & Settings → Optimization → Clear Cache (or CLI `./concrete/bin/concrete5 c5:clear-cache`)
– Refresh Single Pages (find controller): Dashboard → Pages & Themes → Single Pages → “Rescan Single Pages”
or CLI:
./concrete/bin/concrete5 c5:single-pages:refresh
– List routes (to confirm yours are registered):
./concrete/bin/concrete5 c5:routing:list | grep feed
– Controller method exists? If `Feed::view` fatals, Concrete may return 404—check logs: `/application/files/logs/` or Dashboard → Reports → Logs.
# 5) `view()` mode detection (one method)
Inside your controller, keep it simple:
public function view($id = null)
{
$path = rtrim((string)$this->request->getPath(), '/');
$isAdd = (bool)preg_match('#/feed/add$#', $path);
$isEdit = (bool)preg_match('#/feed/edit/d+$#', $path);
$id = $id ?: (int)($this->request->query->get('id') ?? 0);
$mode = ($isAdd) ? 'add' : (($isEdit || $id > 0) ? 'edit' : 'add');
// fetch record if editing…
// $this->set('mode', $mode); etc.
}
# 6) Common pitfalls that cause 404
– Single Page not installed at `/feed`.
– Wrong controller namespace for location (Application vs Package).
– Routes registered in the wrong file or not executed (e.g., `on_start()` never called because package not installed/enabled).
– Cache not cleared after adding controller/routes.
– Pretty URLs off or broken `.htaccess`.
– Case sensitivity on Linux paths (`feed.php` vs `Feed.php`).
—
If you paste your **controller namespace** line and **where you registered the routes**, I’ll pinpoint the exact mismatch.
{
$pages = [
'/dashboard/shopping_feed' => [t('Shopping Feed'), t('Shopping Feed Tools')],
'/dashboard/shopping_feed/view' => [t('Manage Feeds'), t('List and manage feeds'), true],
//'/dashboard/shopping_feed/tracklog' => [t('Tracking Log'), t('View tracking log')],
// '/dashboard/shopping_feed/add' => [t('Add Feed'), t('Add a new feed')],
// '/dashboard/shopping_feed/edit' => [t('Edit Feed'), t('Edit an existing feed')],
'/dashboard/shopping_feed/feed' => [t('Feed'), t('Feed')],
'/dashboard/shopping_feed/settings' => [t('Settings'), t('Settings')],
];
foreach ($pages as $path => $pageData) {
list($name, $desc) = $pageData;
$redirect = $pageData[2] ?? false;
$page = Page::getByPath($path);
if ($page->isError()) {
$page = Single::add($path, $this);
}
if (is_object($page) && !$page->isError()) {
$page->update(['cName' => $name, 'cDescription' => $desc]);
if($path == '/dashboard/shopping_feed/add' || $path == '/dashboard/shopping_feed/edit' || $path == '/dashboard/shopping_feed/feed') {
$page->setAttribute('exclude_nav', true);
}
}
}
// Add alias under Community Store Settings, only if it exists
$pkgService = Core::make(PackageService::class);
$communityStore = $pkgService->getByHandle('community_store');
if ($communityStore && is_object($communityStore)) {
$mainPage = Page::getByPath('/dashboard/shopping_feed');
$parent = Page::getByPath('/dashboard/store');
$aliasExists = Page::getByPath('/dashboard/store/feed');
$isMainPageValid = ($mainPage instanceof Page && !$mainPage->isError());
$isParentValid = ($parent instanceof Page && !$parent->isError());
$aliasAlreadyExists = ($aliasExists instanceof Page && !$aliasExists->isError());
if ($isMainPageValid && $isParentValid && !$aliasAlreadyExists) {
$alias = $mainPage->addCollectionAlias($parent);
if ($alias instanceof Page && !$alias->isError()) {
$alias->update(['cName' => t('Feed')]);
}
}
}
}
we adding like this and all other pages working fine
## 1) Make sure the files/namespace match the Dashboard path
**Files (inside your package):**
/packages/<your_handle>/single_pages/dashboard/shopping_feed/feed.php
/packages/<your_handle>/controllers/single_page/dashboard/shopping_feed/feed.php
**Controller namespace & class:**
<?php
namespace ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreSupportFacadeUrl;
class Feed extends DashboardPageController
{
public function view($id = null)
{
// Detect /add or /edit/{id} while still using ONE method
$path = rtrim((string)$this->request->getPath(), '/');
$isAdd = (bool)preg_match('#/dashboard/shopping_feed/feed/add$#', $path);
$isEdit = (bool)preg_match('#/dashboard/shopping_feed/feed/edit/d+$#', $path);
// Accept id from /feed/{id} (path arg) or /feed/edit/{id} or ?id=
$id = $id ?: (int)($this->request->query->get('id') ?? 0);
$mode = $isAdd ? 'add' : (($isEdit || $id > 0) ? 'edit' : 'add');
// Load record only in edit mode…
// $feed = ...
// $this->set('feed', $feed);
$this->set('mode', $mode);
$this->set('actionURL', Url::to('/dashboard/shopping_feed/feed', 'save')); // POST action (see 3)
}
public function save()
{
// handle create/update, then redirect back nicely:
// return $this->redirect(Url::to('/dashboard/shopping_feed/feed/edit', $newId));
}
public function delete($id = null)
{
// delete then redirect:
// return $this->redirect(Url::to('/dashboard/shopping_feed/feed'));
}
}
> If you put the controller under `/application/…`, change the namespace to `ApplicationControllerSinglePageDashboardShoppingFeed`.
## 2) Register pretty routes that all call `Feed::view()`
Do this in your **package controller** `on_start()`:
use ConcreteCoreRoutingRouter;
public function on_start()
{
$router = $this->app->make(Router::class);
// Every GET goes to Feed::view (one method)
$router->get('/dashboard/shopping_feed/feed', 'ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeedFeed::view');
$router->get('/dashboard/shopping_feed/feed/add', 'ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeedFeed::view');
$router->get('/dashboard/shopping_feed/feed/edit/{id}', 'ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeedFeed::view')
->setRequirements(['id' => 'd+']);
// POST actions (normal actions on the same controller)
$router->post('/dashboard/shopping_feed/feed/save', 'ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeedFeed::save');
$router->post('/dashboard/shopping_feed/feed/delete/{id}', 'ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeedFeed::delete')
->setRequirements(['id' => 'd+']);
}
> If your controller is under `Application…`, update the FQCNs accordingly.
## 3) View file: point links/actions at the pretty routes
In `/single_pages/dashboard/shopping_feed/feed.php`:
<?php
use ConcreteCoreSupportFacadeUrl;
/** @var string $actionURL */
/** @var string $mode */ ?>
<a class="btn btn-primary" href="<?= Url::to('/dashboard/shopping_feed/feed/add') ?>">
<?= t('Add New Feed') ?>
</a>
<!-- Edit buttons in your list -->
<a class="btn btn-sm btn-secondary" href="<?= Url::to('/dashboard/shopping_feed/feed/edit', (int)$row['id']) ?>">
<?= t('Edit') ?>
</a>
<!-- Form submit (create/update) -->
<form method="post" action="<?= h(Url::to('/dashboard/shopping_feed/feed/save')) ?>">
<!-- fields… -->
</form>
<!-- Delete -->
<form method="post" action="<?= Url::to('/dashboard/shopping_feed/feed/delete', (int)$row['id']) ?>" class="d-inline">
<!-- CSRF + submit… -->
</form>
## 4) Your installer (`addDashboardPages`) is fine
You already add **only** `/dashboard/shopping_feed/feed` as a single page — that’s correct.
You **don’t** add `/add` or `/edit` as pages; they’re just routes that map to the same controller method.
## 5) If you still see “Page Not Found”
Quick checks:
– The single page exists: `/dashboard/shopping_feed/feed` opens.
– Controller namespace matches its file location exactly (Package vs Application).
– Routes are actually registered (run: `./concrete/bin/concrete5 c5:routing:list | grep shopping_feed`).
– Clear cache: Dashboard → System & Settings → Optimization → Clear Cache (or CLI).
– Try `…/index.php/dashboard/shopping_feed/feed/add`. If that works, fix Pretty URLs (`.htaccess`) though dashboard usually doesn’t need special rules beyond the site’s.
This setup gives you:
– `/dashboard/shopping_feed/feed` → add mode
– `/dashboard/shopping_feed/feed/add` → add mode (same `view()`)
– `/dashboard/shopping_feed/feed/edit/2` → edit ID 2 (same `view()`)
…all handled by **one** method: `Feed::view()`.
## Quickest fix (works even if routes aren’t registering)
Since Concrete’s dispatcher treats `/dashboard/…/feed/edit/2` as **an action** called `edit`, it 404s because that method doesn’t exist. Add two tiny **proxy methods** in your controller that *just call* `view()`:
**`/packages//controllers/single_page/dashboard/shopping_feed/feed.php`**
<?php
namespace ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
class Feed extends DashboardPageController
{
public function view($id = null)
{
// your existing view logic
// infer $mode = add/edit and load $feed if $id > 0...
}
// PROXY: /dashboard/shopping_feed/feed/add → same view (add mode)
public function add()
{
return $this->view(null);
}
// PROXY: /dashboard/shopping_feed/feed/edit/2 → same view with id
public function edit($id = null)
{
return $this->view((int)$id);
}
}
No routing config needed. This immediately makes:
– `/dashboard/shopping_feed/feed/add` → **view()** (add mode)
– `/dashboard/shopping_feed/feed/edit/2` → **view(2)** (edit mode)
> You’re still rendering only the single `feed.php` view. These methods are just tiny pass-throughs so the dispatcher doesn’t 404.
Now **clear cache** (Dashboard → System & Settings → Optimization → Clear Cache) and try again.
—
## If you still prefer pretty routes that *map to view()*
If you *must* keep only `view()` and want `/edit/{id}` to call `view` directly via the router, double-check these **three exact points**:
1) **Controller FQCN matches location**
File path:
/packages/<your_handle>/controllers/single_page/dashboard/shopping_feed/feed.php
Namespace/class:
namespace ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeed;
class Feed extends DashboardPageController { ... }
2) **Routes registered in your package `on_start()` using the same FQCN**
public function on_start()
{
$r = $this->app->make(ConcreteCoreRoutingRouter::class);
$f = 'ConcretePackage<YourHandle>ControllerSinglePageDashboardShoppingFeedFeed::view';
$r->get('/dashboard/shopping_feed/feed', $f);
$r->get('/dashboard/shopping_feed/feed/add', $f);
$r->get('/dashboard/shopping_feed/feed/edit/{id}', $f)->setRequirements(['id' => 'd+']);
}
3) **Verify and flush**
– CLI: `./concrete/bin/concrete5 c5:routing:list | grep shopping_feed` → you should see your three GET routes.
– Clear cache.
If the routes **don’t** show up, your `on_start()` isn’t running (package not installed/enabled) or FQCN mismatch.
—
## One more common gotcha in your installer
You add `/dashboard/shopping_feed/view` and sometimes redirect the parent to it. If your main nav is taking you to `/dashboard/shopping_feed` but you only built the controller under `/dashboard/shopping_feed/feed`, make sure you’re actually visiting the **feed** page URLs:
– Go directly to:
`/index.php/dashboard/shopping_feed/feed/add`
`/index.php/dashboard/shopping_feed/feed/edit/2`
If those work with `/index.php/` but not without, it’s a Pretty URLs issue (less common in dashboard, but worth checking).
—
## TL;DR
– **Guaranteed fix right now:** add `add()` and `edit($id)` proxies that call `view()`. No routes required.
– If you want routing-only: ensure FQCN matches controller location, register in `on_start()`, confirm with `c5:routing:list`, clear cache.
If you paste your controller namespace line (first few lines of the file) and where the file lives, I can pinpoint the exact mismatch.
In Concrete CMS, a Single Page (DashboardPageController) lets you override the page title with:
$this->set('pageTitle', 'My Title');
…and in the view template use “ instead of hard-coded “.
—
### 1) Controller: set the title
In your controller (`feed.php`):
public function view($id = null)
{
$path = rtrim((string)$this->request->getPath(), '/');
$isAdd = (bool)preg_match('#/feed/add$#', $path);
$isEdit = (bool)preg_match('#/feed/edit/d+$#', $path);
$id = $id ?: (int)($this->request->query->get('id') ?? 0);
if ($isAdd) {
$mode = 'add';
$title = t('Add Feed');
} elseif ($isEdit || $id > 0) {
$mode = 'edit';
$title = t('Edit Feed');
} else {
$mode = 'add';
$title = t('Feed');
}
$this->set('mode', $mode);
$this->set('pageTitle', $title);
// load feed record if needed...
}
—
### 2) View file: use `$pageTitle`
In `/single_pages/dashboard/shopping_feed/feed.php`:
<?php
defined('C5_EXECUTE') or die("Access Denied");
/** @var string $pageTitle */
?>
<div class="ccm-ui">
<h2><?= h($pageTitle) ?></h2>
<!-- your form here -->
</div>
—
### 3) Dashboard header bar (optional)
If you want the **browser page title** (top “ in admin header) to also change, you can call:
$this->set('pageTitle', $title);
$this->setPageTitle($title);
Or override `getPageTitle()` in your controller:
public function getPageTitle()
{
return $this->get('pageTitle') ?: t('Feed');
}
—
✅ Result:
– `/dashboard/shopping_feed/feed/add` → heading: **Add Feed**
– `/dashboard/shopping_feed/feed/edit/2` → heading: **Edit Feed**
– `/dashboard/shopping_feed/feed` → heading: **Feed**
Do you also want the **breadcrumb / top dashboard bar title** (not just your “) to show “Add Feed” vs “Edit Feed”?