Updated code without db.xml
August 23, 2025 · AI Conversations
# 1) Routing (replace legacy `Route::register`)
**Why:** The `Route` facade is legacy. Marketplace reviewers prefer the v9 router with explicit methods + middleware (CSRF/permissions).
**Do this in `on_start()` (or better: a dedicated `RouteList`):**
use ConcreteCoreRoutingRouterInterface;
use ConcreteCoreRoutingGroupGroup;
use ConcreteCoreHttpMiddlewareMiddlewareStack;
use ConcreteCoreFoundationServiceProviderList;
use ConcreteCorePermissionMiddlewareRequireAuthenticatedUserMiddleware;
use ConcreteCorePermissionMiddlewareRequirePermissionsMiddleware;
use ConcreteCoreValidationCSRFMiddlewareValidateCSRFTokenMiddleware;
public function on_start()
{
$router = $this->app->make(RouterInterface::class);
// Dashboard API group: must be logged in + dashboard perms + CSRF for state‑changing routes
$router->buildGroup()
->setPrefix('/ccm/system/shopping_feed') // keep custom APIs out of public root
->addMiddleware($this->app->make(RequireAuthenticatedUserMiddleware::class))
->addMiddleware($this->app->make(RequirePermissionsMiddleware::class)) // checks dashboard perms
->routes(function (Group $group) {
$group->get('/feeds', 'ConcretePackageShoppingFeedManagerControllerApiFeedApi::view');
$group->post('/delete/{id}', 'ConcretePackageShoppingFeedManagerControllerApiFeedApi::delete')
->addMiddleware($this->app->make(ValidateCSRFTokenMiddleware::class));
$group->post('/clone/{id}', 'ConcretePackageShoppingFeedManagerControllerApiFeedApi::cloneFeed')
->addMiddleware($this->app->make(ValidateCSRFTokenMiddleware::class));
});
// Public read‑only endpoint — if you truly want it public, keep it GET
$router->register('/google-merchant-feed/{feedSlug}',
'ConcretePackageShoppingFeedManagerControllerMerchantFeed::generateXml')
->setMethods(['GET']);
}
**And in your controllers (`FeedApi`):** validate CSRF tokens (for POST) and permissions (e.g., `canAccessDashboard()` or a custom Permission Key).
—
# 2) Assets: register + require via controllers, not by sniffing `Request` path
**Why:** Manually checking `Request` path in `on_start()` is brittle. Marketplace prefers assets required from the Single Page’s controller (`view()`), or via an asset group.
**Keep registration in `on_start()`, but remove the path check.**
public function on_start()
{
// ...
$al = AssetList::getInstance();
$al->register('javascript', 'shopping_feed/admin', 'js/admin-feed.js', ['version' => '1.0.0'], $this);
$al->register('css', 'shopping_feed/admin', 'css/admin-feed.css', ['version' => '1.0.0'], $this);
$al->registerGroup('shopping_feed/admin', [
['javascript', 'shopping_feed/admin'],
['css', 'shopping_feed/admin'],
]);
}
**In `/controllers/single_page/dashboard/shopping_feed.php` (and sub‑pages):**
public function view()
{
$this->requireAsset('javascript', 'shopping_feed/admin');
$this->requireAsset('css', 'shopping_feed/admin');
// ...
}
—
# 3) Database schema: move raw SQL into `db.xml`
**Why:** Marketplace guidelines favor schema in `db.xml` with Doctrine installer/refresh. Also add proper indexes/constraints.
**`packages/shopping_feed_manager/db.xml`:**
<schema xmlns="http://www.concrete5.org/doctrine-xml/0.5">
<table name="shoppingfeedconfiguration">
<field name="id" type="integer" unsigned="true">
<options>
<option name="autoincrement">true</option>
</options>
</field>
<field name="url" type="string" length="255" />
<field name="name" type="string" length="255" />
<field name="feedSlug" type="string" length="255" />
<field name="settings" type="text" nullable="true" />
<field name="created_at" type="datetime" nullable="false" />
<field name="latestGeneration" type="datetime" nullable="true" />
<field name="noOfProducts" type="integer" unsigned="true" default="0" />
<field name="updated_at" type="datetime" nullable="false" />
<field name="enableCron" type="boolean" default="0" />
<id name="id" columns="id" />
<unique-constraint name="uniq_sfm_url"><unique-columns>url</unique-columns></unique-constraint>
<unique-constraint name="uniq_sfm_slug"><unique-columns>feedSlug</unique-columns></unique-constraint>
<index name="idx_sfm_name"><index-columns>name</index-columns></index>
</table>
<table name="shoppingfeedlog">
<field name="id" type="integer" unsigned="true">
<options><option name="autoincrement">true</option></options>
</field>
<field name="feed_id" type="integer" unsigned="true" />
<field name="updated_at" type="datetime" />
<field name="status" type="string" length="50" nullable="true" />
<field name="error_message" type="text" nullable="true" />
<id name="id" columns="id" />
<index name="idx_sfl_feed"><index-columns>feed_id</index-columns></index>
<index name="idx_sfl_status"><index-columns>status</index-columns></index>
<index name="idx_sfl_updated_at"><index-columns>updated_at</index-columns></index>
<foreign-key foreignTable="shoppingfeedconfiguration" onDelete="CASCADE">
<reference local="feed_id" foreign="id"/>
</foreign-key>
</table>
</schema>
**Installer/upgrade now uses the schema manager (and remove `createTables()`):**
use ConcreteCoreDatabaseDatabaseStructureManager;
public function install()
{
$pkg = parent::install();
$this->installContentFile('tasks.xml');
$this->addDashboardPages();
$manager = $this->app->make(DatabaseStructureManager::class);
$manager->installFromPackage($this);
}
public function upgrade()
{
parent::upgrade();
$this->addDashboardPages();
$manager = $this->app->make(DatabaseStructureManager::class);
$manager->installFromPackage($this);
}
—
# 4) Autoloading & folder layout
**Why:** Reviewers look for PSR‑4 autoload instead of manual `vendor/autoload.php` includes in `on_start()`.
– Add `getPackageAutoloaderRegistries()` to map `src`:
public function getPackageAutoloaderRegistries()
{
return [
'src' => [
'Concrete\Package\ShoppingFeedManager' => 'src',
],
];
}
– Move your classes under `packages/shopping_feed_manager/src/…`
– e.g., `src/Command/Task/Controller/GenerateMerchantFeedController.php`
– `src/Controller/Api/FeedApi.php`
– `src/Controller/MerchantFeed.php`
– `src/Controller/SinglePage/Dashboard/ShoppingFeed.php` etc.
– If you truly need Composer deps, ship a `composer.json` at package root and commit the built `vendor/` (Marketplace requires no external downloads) — *but don’t* `require_once vendor/autoload.php` manually in `on_start()`; Concrete will include it via the package loader.
—
# 5) Tasks: register via `TaskManager` service provider pattern
Your current extend is fine, but keep it in `on_start()` **after** autoloading is established (which the registry above handles). Also ensure the task controller class names/paths match the autoloader.
—
# 6) Security hardening for endpoints
**Why:** Marketplace checks this closely.
– **Use POST** for `delete`/`clone` (already shown), enforce CSRF and permissions middleware.
– In controllers, add guardrails:
– Validate IDs (int), ownership/permissions.
– Return JSON with proper HTTP codes.
– Escape any reflected data.
– For the public feed endpoint:
– Rate‑limit (optional).
– Validate `feedSlug` (safe pattern like `^[a-z0-9-]+$`).
– Send proper headers: `Content-Type: application/xml; charset=utf-8`.
—
# 7) Dashboard pages & aliasing
Your `addDashboardPages()` is OK; a few polish points:
– Avoid creating `/dashboard/shopping_feed/view` and redirecting to its parent. Instead, make `/dashboard/shopping_feed` the “index” page and list feeds there. If you keep the redirect, ensure it **doesn’t** create an extra SP page (you do that now, then redirect away). Simpler:
– Remove `/dashboard/shopping_feed/view` altogether.
– When adding the alias under Community Store, keep the handle neutral and localized name:
if ($communityStore) {
$mainPage = Page::getByPath('/dashboard/shopping_feed');
$parent = Page::getByPath('/dashboard/store');
if ($mainPage && !$mainPage->isError() && $parent && !$parent->isError()) {
$aliasExists = Page::getByPath('/dashboard/store/feed');
if ($aliasExists->isError()) {
$alias = $mainPage->addCollectionAlias($parent);
if ($alias && !$alias->isError()) {
$alias->update(['cName' => t('Feed')]);
}
}
}
}
– You’re correctly hiding `add`/`edit` from nav via `exclude_nav`.
—
# 8) Uninstall cleanup
**Why:** Required by Marketplace.
public function uninstall()
{
parent::uninstall();
// Optional: no need to manually drop tables if using db.xml — Concrete removes them.
// But do remove aliases/pages you added programmatically if needed.
}
—
# 9) Version & compatibility metadata
– Set `$appVersionRequired` to a concrete minor that you’ve tested (e.g., `'9.2.0'`).
– Keep `$pkgVersion` semantic (e.g., `1.0.0`).
– Ensure `controller.php` has `getPackageName()`/`getPackageDescription()` (you do).
—
# 10) Coding/style polish the reviewers look for
– **Translations:** wrap user‑visible strings in `t()` (you already do).
– **Strict types:** optional but appreciated (`declare(strict_types=1);` on PHP 8+).
– **Return types:** add to methods where possible (`: void`, `: array`, etc.).
– **Deprecations:** remove `use Route;` entirely after migrating to `RouterInterface`.
—
# 11) Feed generation output
– Ensure the XML generator:
– Uses a stream response (`SymfonyComponentHttpFoundationStreamedResponse`) for large feeds.
– Sets caching headers (e.g., `Cache-Control: public, max-age=300`) if appropriate.
– Validates against Google Merchant specs (ids, price format w/ currency, availability, condition).
—
# 12) README + Marketplace artifacts
– Add a **README.md** with:
– Requirements, install steps, where to find dashboard pages.
– Instructions to create a feed, the public feed URL pattern.
– Cron/task usage (`generate_merchant_feed` task name, examples).
– Add **LICENSE** (MIT or similar).
– Add **icon.png** and **thumbnail.png** for the Marketplace listing.
—
## Quick diff against your current file
– [ ] Replace all `Route::register(…)` with `RouterInterface` registrations + middleware.
– [ ] Remove `Request` path sniffing; require assets in SP controllers; keep asset registration in `on_start()`.
– [ ] Remove `createTables()`; add `db.xml`; install via `DatabaseStructureManager`.
– [ ] Add `getPackageAutoloaderRegistries()` and move classes to `src/`.
– [ ] Harden API with POST + CSRF + auth/permissions.
– [ ] Drop the `/dashboard/shopping_feed/view` redirect route; simplify dashboard structure.
– [ ] Add uninstall cleanup and bump `$appVersionRequired` to the lowest v9 minor you support.
– [ ] README, LICENSE, images.
If you want, paste your `FeedApi` + `MerchantFeed` controllers next—I’ll wire in the CSRF/permissions and response types in place.
Want me to also add an **`uninstall()` cleanup** that drops these tables, so Marketplace reviewers don’t flag leftover DB artifacts?
✅ Fix: remove extra elements from `$pages` or adjust destructuring so `cName` and `cDescription` always get strings.
Even though we cleaned up the `$pages` array, PHP array destructuring can still bite if anything odd sneaks in. Let’s make the method fully defensive (no destructuring, cast to string, and avoid passing booleans to attributes).
Please replace your entire `addDashboardPages()` with this version:
protected function addDashboardPages(): void
{
// Define pages as [name, description]
$pages = [
'/dashboard/shopping_feed' => [t('Shopping Feed'), t('Shopping Feed Tools')],
'/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/settings' => [t('Settings'), t('Settings')],
];
foreach ($pages as $path => $pageData) {
// Defensive: normalize to strings to avoid any internal trim() on arrays
$name = isset($pageData[0]) ? (string) $pageData[0] : '';
$desc = isset($pageData[1]) ? (string) $pageData[1] : '';
$page = Page::getByPath($path);
if (!is_object($page) || $page->isError()) {
$page = Single::add($path, $this);
}
if ($page && !$page->isError()) {
$page->update([
'cName' => $name,
'cDescription' => $desc,
]);
if (in_array($path, ['/dashboard/shopping_feed/add', '/dashboard/shopping_feed/edit'], true)) {
// Pass a scalar (1) instead of boolean to avoid edge cases in attribute controllers
$page->setAttribute('exclude_nav', 1);
}
}
}
// Optional alias under Community Store
$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' => (string) t('Feed')]);
}
}
}
}
Why this fixes it:
– No destructuring (`[$name, $desc]`) that can accidentally produce arrays.
– Explicit `(string)` casts before calling `update()`.
– `exclude_nav` set with `1` (scalar) to avoid any attribute controller edge cases.
If you still see the error after this replacement:
1) Grab the stack trace (it will point to the exact line). In your local/dev env, ensure errors show: set `concrete.debug.display_errors` to `true` in `application/config/concrete.php` (or through the dashboard in Debug settings).
2) Check any **single page controller** for `set()` calls where you might be doing something like `$this->set('pageTitle', ['foo' => 'bar'])`; Core often trims titles/labels too.
3) Search your package for any direct `trim(` calls being given arrays, or any `update([…])` where a value might be an array.
Want me to paste this method directly into the canvas file for you?
– Modern router (`RouterInterface`) instead of `Route::register`
– Secure API grouping under `/ccm/system/shopping_feed` with auth + permissions + CSRF on POST
– Public GET route for `/google-merchant-feed/{feedSlug}`
– Asset registration via an asset group (require from your SP controllers)
– `createTables()` wired into both `install()` and `upgrade()`
– Safe `addDashboardPages()` (no `/view` page; casts to strings to avoid the `trim()` error)
If you still see `trim(): Argument #1 ($string) must be of type string, array given`, it usually means an **existing** Single Page in the DB has a non‑string `cName`/`cDescription` from your earlier code. Quick fix:
1) Dashboard → System & Settings → Optimization → Clear Cache.
2) Temporarily remove the pages, then re-add:
– Go to `/dashboard/sitemap/full` → find `/dashboard/shopping_feed/*` → delete the old ones.
– In your code, run the package upgrade to recreate them.
CLI alternative:
# from Concrete public root
./concrete/bin/concrete5 c5:clear-cache
./concrete/bin/concrete5 c5:package:upgrade shopping_feed_manager
Want me to also add the Single Page controllers that call `$this->requireAsset('shopping_feed/admin')` so assets load the “Concrete way”?