Asset loading optimization
August 23, 2025 · AI Conversations
Asset loading pattern
suggestion one by one so that i can replace
—
# 1) Delete path‑based requiring from your package controller
**Remove this block** from `on_start()`:
$req = $this->app->make(Request::class);
$path = $req->getPath();
if (str_starts_with($path, '/dashboard/shopping_feed')) {
View::getInstance()->requireAsset('javascript', 'feed-admin-js');
View::getInstance()->requireAsset('css', 'feed-admin-css');
}
> We’ll keep registration in the package, but we’ll require assets only where they’re needed (in the Single Page).
—
# 2) Register assets once (keep this in `on_start()`)
**Keep (or add) only the registration** in your package `controller.php`:
use ConcreteCoreAssetAssetList;
public function on_start(): void
{
// (composer autoload, routes, tasks …)
// Register assets (no require here)
$al = AssetList::getInstance();
$al->register(
'javascript',
'sfm/admin',
'js/admin-feed.js',
['version' => $this->getPackageVersion(), 'position' => ConcreteCoreAssetAsset::ASSET_POSITION_FOOTER],
$this
);
$al->register(
'css',
'sfm/admin',
'css/admin-feed.css',
['version' => $this->getPackageVersion()],
$this
);
}
Notes:
– Use concise handles like `sfm/admin`.
– Setting `position` puts JS in footer.
– Version ties to your package version for cache busting.
—
# 3) Require assets in the **Single Page controller** (recommended)
Create or edit `/packages/shopping_feed_manager/controllers/single_page/dashboard/shopping_feed.php`:
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboard;
use ConcreteCorePageControllerDashboardPageController;
class ShoppingFeed extends DashboardPageController
{
public function on_start(): void
{
// Require assets here so they load only on this page (and its sub-actions)
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
public function view(): void
{
// your view logic
}
}
Why here?
– Dashboard pages are protected and scoped.
– This avoids loading assets globally or doing brittle path checks.
—
# 4) (Alternative) Require in the page’s `view.php` (if you prefer views)
If you don’t want to touch the controller, you can require inside `/single_pages/dashboard/shopping_feed/view.php`:
<?php
defined('C5_EXECUTE') or die('Access Denied.');
use ConcreteCoreViewView;
$view = View::getInstance();
$view->requireAsset('javascript', 'sfm/admin');
$view->requireAsset('css', 'sfm/admin');
?>
<!-- rest of your markup -->
Either controller **or** view is fine—don’t do both.
—
# 5) (Optional) Load only on some subpages
If you have multiple Single Pages (e.g., `/dashboard/shopping_feed/add`, `/edit`), and you want the assets **only** on some of them, put the `requireAsset()` calls in **those specific** controllers’ `on_start()` or `view()` methods instead of the main page.
—
# 6) (Optional) Use an asset group (one handle to rule them all)
If you’ll add more files later, register a group in `on_start()`:
$al = AssetList::getInstance();
$al->registerGroup('sfm/admin-group', [
['javascript', 'sfm/admin'],
['css', 'sfm/admin'],
]);
Then require the group in the Single Page controller:
$this->requireAsset('javascript', 'sfm/admin-group');
$this->requireAsset('css', 'sfm/admin-group');
(Concrete will resolve both.)
—
# 7) Folder locations (unchanged)
Keep your files at:
/packages/shopping_feed_manager/js/admin-feed.js
/packages/shopping_feed_manager/css/admin-feed.css
`$this` (the package object) makes those paths resolve correctly when registering.
—
If you want, tell me which Single Pages should actually load the assets (e.g., only `/add` and `/edit`), and I’ll give you precise `on_start()` snippets for those controllers only.
// manually output in header from view/controller
View::getInstance()->requireAsset('javascript', 'sfm/admin');
View::getInstance()->requireAsset('css', 'sfm/admin');
}
this is required on;y on add/edit [page
# 1) Stop requiring assets on the base page
Leave `/dashboard/shopping_feed` without assets (so your listing page stays light).
**controllers/single_page/dashboard/shopping_feed.php**
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboard;
use ConcreteCorePageControllerDashboardPageController;
class ShoppingFeed extends DashboardPageController
{
public function on_start(): void
{
// no assets here
}
public function view(): void
{
// listing page logic
}
}
# 2) Require assets on `/dashboard/shopping_feed/add`
Create a dedicated controller so assets load only on the add page.
**controllers/single_page/dashboard/shopping_feed/add.php**
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
class Add extends DashboardPageController
{
public function on_start(): void
{
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
public function view(): void
{
// add page logic
}
}
# 3) Require assets on `/dashboard/shopping_feed/edit`
Same idea for edit.
**controllers/single_page/dashboard/shopping_feed/edit.php**
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
class Edit extends DashboardPageController
{
public function on_start(): void
{
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
public function view(): void
{
// edit page logic
}
}
# Folder map (just to be sure)
packages/shopping_feed_manager/
controllers/
single_page/
dashboard/
shopping_feed.php // base (no assets)
shopping_feed/
add.php // requires assets
edit.php // requires assets
single_pages/
dashboard/
shopping_feed/view.php
shopping_feed/add.php
shopping_feed/edit.php
# Reminder
Keep **registration** of assets in your package `controller.php` (no `require` there):
$al = ConcreteCoreAssetAssetList::getInstance();
$al->register('javascript', 'sfm/admin', 'js/admin-feed.js', ['version' => $this->getPackageVersion()], $this);
$al->register('css', 'sfm/admin', 'css/admin-feed.css', ['version' => $this->getPackageVersion()], $this);
That’s it—assets will only load on Add/Edit. Want me to drop these three controllers into your canvas exactly as files?
when added public function on_start(): void
{
// Require assets here so they load only on this page (and its sub-actions)
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
on ad controller
## 1) Make sure you only **register** assets in the package controller (no `require` there)
In `packages/shopping_feed_manager/controller.php`:
use ConcreteCoreAssetAssetList;
use ConcreteCoreAssetAsset;
public function on_start(): void
{
if (is_file($autoload = __DIR__ . '/vendor/autoload.php')) {
require $autoload;
}
// …routes, tasks…
// Register only (do NOT call requireAsset here)
$al = AssetList::getInstance();
$al->register('javascript', 'sfm/admin', 'js/admin-feed.js', [
'version' => $this->getPackageVersion(),
'position' => Asset::ASSET_POSITION_FOOTER,
], $this);
$al->register('css', 'sfm/admin', 'css/admin-feed.css', [
'version' => $this->getPackageVersion(),
], $this);
}
## 2) Move the page-level `requireAsset()` to a later hook
On Single Page controllers, use `on_before_render()` instead of `on_start()` so the View is fully initialized:
**controllers/single_page/dashboard/shopping_feed/add.php**
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
class Add extends DashboardPageController
{
public function on_before_render(): void
{
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
public function view(): void {}
}
**controllers/single_page/dashboard/shopping_feed/edit.php**
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
class Edit extends DashboardPageController
{
public function on_before_render(): void
{
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
public function view(): void {}
}
> Tip: Put **one class per file** (i.e., `Add` in `add.php`, `Edit` in `edit.php`). Mixing both in one file can confuse autoloading and controller resolution.
## 3) Double‑check the handle + paths
– Handle must match exactly: `'sfm/admin'`.
– Files should exist at:
– `packages/shopping_feed_manager/js/admin-feed.js`
– `packages/shopping_feed_manager/css/admin-feed.css`
## 4) Clear cache
After changes: Dashboard → System & Settings → Optimization → **Clear Cache** (or delete `application/files/cache/*`), then reload the page.
## 5) If you still prefer views
You can instead require from the view file (guaranteed late enough):
**single_pages/dashboard/shopping_feed/add.php**
<?php
defined('C5_EXECUTE') or die('Access Denied.');
use ConcreteCoreViewView;
$view = View::getInstance();
$view->requireAsset('javascript', 'sfm/admin');
$view->requireAsset('css', 'sfm/admin');
?>
<!-- Add page markup -->
Try step 2 first (move to `on_before_render()` + split files). That typically resolves the “call to … get() on null” in Concrete v9.
HTTP 500 Internal Server Error
Call to a member function generate() on nu
## Quick fix (works even if controller wiring is off)
Move the requiring into the **view** files so it runs late enough:
**single_pages/dashboard/shopping_feed/add.php**
<?php
defined('C5_EXECUTE') or die('Access Denied.');
use ConcreteCoreViewView;
$view = View::getInstance();
$view->requireAsset('javascript', 'sfm/admin');
$view->requireAsset('css', 'sfm/admin');
?>
<!-- Add page markup -->
**single_pages/dashboard/shopping_feed/edit.php**
<?php
defined('C5_EXECUTE') or die('Access Denied.');
use ConcreteCoreViewView;
$view = View::getInstance();
$view->requireAsset('javascript', 'sfm/admin');
$view->requireAsset('css', 'sfm/admin');
?>
<!-- Edit page markup -->
Then remove `on_before_render()` from the Add/Edit controllers for now.
—
## If you want controller‑based requiring (the “right” way), check these 5 things
1) **One class per file + correct paths**
packages/shopping_feed_manager/
controllers/single_page/dashboard/shopping_feed/add.php -> class Add
controllers/single_page/dashboard/shopping_feed/edit.php -> class Edit
2) **Namespaces & class names must match the path**
Each file should contain just one class:
**controllers/single_page/dashboard/shopping_feed/add.php**
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
class Add extends DashboardPageController
{
public function on_before_render(): void
{
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
public function view(): void {}
}
**controllers/single_page/dashboard/shopping_feed/edit.php**
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboardShoppingFeed;
use ConcreteCorePageControllerDashboardPageController;
class Edit extends DashboardPageController
{
public function on_before_render(): void
{
$this->requireAsset('javascript', 'sfm/admin');
$this->requireAsset('css', 'sfm/admin');
}
public function view(): void {}
}
> Don’t put both `Add` and `Edit` classes in the **same** file. Concrete resolves controllers by path and expects a single class per file.
3) **Single Pages actually exist**
Make sure you created these pages (install step or manually):
– `/dashboard/shopping_feed/add`
– `/dashboard/shopping_feed/edit`
4) **Assets are registered in package controller (not required)**
$al = ConcreteCoreAssetAssetList::getInstance();
$al->register('javascript', 'sfm/admin', 'js/admin-feed.js', ['version' => $this->getPackageVersion()], $this);
$al->register('css', 'sfm/admin', 'css/admin-feed.css', ['version' => $this->getPackageVersion()], $this);
5) **Clear caches**
Dashboard → System & Settings → Optimization → **Clear Cache**.
—
## Still seeing “generate() on null”?
Flip on debug temporarily to see the exact stack:
– In `application/config/concrete.php` set:
'debug' => [
'display_errors' => true,
],
– Or run: `concrete5 c5:config set concrete.debug.display_errors true`
Then share the top 5 lines of the stack trace with me—especially the class where `generate()` is called. I’ll pinpoint it.
– Deletes the **Community Store alias** (`/dashboard/store/feed`)
– Deletes your **dashboard single pages** (belt‑and‑suspenders—Concrete usually removes package pages automatically, but this is explicit)
– **Drops your tables** (if you’re keeping schema in `db.xml` and want data removed)
– Notes about routes (nothing to deregister—routes exist only while the package is installed)
use ConcreteCorePagePage;
use ConcreteCoreDatabaseConnectionConnection;
public function uninstall(): void
{
// 1) Remove the optional alias under Community Store
$alias = Page::getByPath('/dashboard/store/feed');
if ($alias && !$alias->isError()) {
// Only delete if it's actually an alias pointing to our page
// (isAlias() exists on Page; if not available in your version, skip the check)
if (method_exists($alias, 'isAlias')) {
if ($alias->isAlias()) {
$alias->delete();
}
} else {
// Fallback: best-effort delete
$alias->delete();
}
}
// 2) Remove our dashboard single pages explicitly (Concrete usually does this automatically)
$paths = [
'/dashboard/shopping_feed',
'/dashboard/shopping_feed/add',
'/dashboard/shopping_feed/edit',
'/dashboard/shopping_feed/settings',
// '/dashboard/shopping_feed/view', // add if you actually created it as a page
];
foreach ($paths as $path) {
$p = Page::getByPath($path);
if ($p && !$p->isError()) {
$p->delete();
}
}
// 3) Drop package tables (ONLY if you want to remove data on uninstall)
// If you prefer to keep user data, comment this block out.
$db = $this->app->make(Connection::class);
// DBAL 3+: SchemaManager via createSchemaManager()
if (method_exists($db, 'createSchemaManager')) {
$sm = $db->createSchemaManager();
foreach (['shoppingfeedlog', 'shoppingfeedconfiguration'] as $table) {
if ($sm->tablesExist([$table])) {
$db->executeStatement("DROP TABLE IF EXISTS `$table`");
}
}
} else {
// Older DBAL fallback
foreach (['shoppingfeedlog', 'shoppingfeedconfiguration'] as $table) {
$db->executeQuery("DROP TABLE IF EXISTS `$table`");
}
}
// 4) Finally, run parent uninstall (removes the package record, clears caches, etc.)
parent::uninstall();
}
### Notes
– **Routes:** You don’t need to “deregister” routes. Your routes are registered in `on_start()` via `RouteList`; once the package is uninstalled, `on_start()` never runs, so those routes disappear automatically.
– **Assets:** Nothing to clean up—registered asset handles live only while the package is installed.
– **Data retention:** Marketplace reviewers often prefer keeping user data on uninstall. If that’s your policy, comment out the DROP TABLE section and add the policy to your README.
Want me to add this directly into your canvas controller file and adjust the `use` imports for you?
Here’s the **marketplace‑friendly uninstall** you can paste into your package controller. It **keeps data**, removes only the alias we created, and relies on Concrete to remove the package’s Single Pages. I also include an **optional purge** method if you explicitly want to nuke data.
<?php
use ConcreteCorePagePage;
use ConcreteCoreDatabaseConnectionConnection;
/**
* Marketplace‑compatible uninstall:
* - Keeps user data (tables retained)
* - Removes package-created alias under Community Store
* - Lets Concrete remove Single Pages for this package
*/
public function uninstall(): void
{
// 1) Remove the optional alias under Community Store (created during install)
$alias = Page::getByPath('/dashboard/store/feed');
if ($alias && !$alias->isError()) {
// Only delete if it's actually an alias (best-effort safety)
if (method_exists($alias, 'isAlias')) {
if ($alias->isAlias()) {
$alias->delete();
}
} else {
// Fallback for versions without isAlias()
$alias->delete();
}
}
// 2) Do NOT drop DB tables by default (Marketplace prefers data retention).
// If you need a “purge everything” option, see uninstallPurgeAll() below.
// 3) Finish with parent uninstall (removes package record, clears caches, etc.)
parent::uninstall();
}
/**
* OPTIONAL: Purge all package data on uninstall (NOT recommended for Marketplace).
* If a customer requests full data removal, you can call this instead of uninstall().
*/
public function uninstallPurgeAll(): void
{
// Remove alias if present (same logic as above)
$alias = Page::getByPath('/dashboard/store/feed');
if ($alias && !$alias->isError()) {
if (method_exists($alias, 'isAlias')) {
if ($alias->isAlias()) {
$alias->delete();
}
} else {
$alias->delete();
}
}
// Drop package tables — ONLY if you truly want to erase all stored data
$db = $this->app->make(Connection::class);
if (method_exists($db, 'createSchemaManager')) {
// Doctrine DBAL 3+
$sm = $db->createSchemaManager();
foreach (['shoppingfeedlog', 'shoppingfeedconfiguration'] as $table) {
if ($sm->tablesExist([$table])) {
$db->executeStatement("DROP TABLE IF EXISTS `{$table}`");
}
}
} else {
// Older DBAL fallback
foreach (['shoppingfeedlog', 'shoppingfeedconfiguration'] as $table) {
$db->executeQuery("DROP TABLE IF EXISTS `{$table}`");
}
}
parent::uninstall();
}
### Why this is marketplace‑compatible
– No raw SQL in the default uninstall (and no schema manipulation unless you opt into purge).
– Data retention by default (reviewers prefer not to destroy user data).
– No manual Single Page deletion (Concrete removes package pages automatically).
– Cleans up only what we created outside the package tree (the Community Store alias).
If you want, I can try updating the canvas again with a simpler, smaller edit (less likely to trip the regex).
# 1) Define schema in `db.xml` (no SQL in PHP)
Create `/packages/shopping_feed_manager/db.xml`:
<?xml version="1.0" encoding="UTF-8"?>
<schema version="0.3">
<table name="shoppingfeedconfiguration">
<field name="id" type="I" size="11" unsigned="true" autoincrement="true" primary="true"/>
<field name="url" type="C" size="255" notnull="true"/>
<field name="name" type="C" size="255" notnull="true"/>
<field name="feedSlug" type="C" size="255" notnull="true"/>
<field name="settings" type="X"/>
<field name="created_at" type="T"/>
<field name="latestGeneration" type="T"/>
<field name="noOfProducts" type="I" size="11" default="0"/>
<field name="updated_at" type="T"/>
<field name="enableCron" type="I" size="1" default="0"/>
<index name="idx_name"><col>name</col></index>
<index name="idx_url_unique" unique="true"><col>url</col></index>
</table>
<table name="shoppingfeedlog">
<field name="id" type="I" size="11" unsigned="true" autoincrement="true" primary="true"/>
<field name="feed_id" type="I" size="11" notnull="true"/>
<field name="updated_at" type="T" notnull="true"/>
<field name="status" type="C" size="50"/>
<field name="error_message" type="X"/>
<index name="idx_feed"><col>feed_id</col></index>
<index name="idx_status"><col>status</col></index>
<index name="idx_updated_at"><col>updated_at</col></index>
</table>
</schema>
Notes:
– `type="C"` (varchar), `type="I"` (int), `type="T"` (datetime), `type="X"` (text).
– Use **indexes** and **unique** constraints here (reviewers like this).
# 2) Remove custom SQL from your package controller
Delete any `$db->executeQuery("CREATE TABLE …")` calls. Concrete will read `db.xml` on **install** and **upgrade** and apply diffs automatically.
# 3) Install / upgrade unchanged (no special code)
This is enough in your controller:
public function install(): void
{
$pkg = parent::install();
$this->installContentFile('tasks.xml'); // if you use it
$this->addDashboardPages(); // your helper
}
public function upgrade(): void
{
parent::upgrade();
$this->installContentFile('tasks.xml'); // idempotent
$this->addDashboardPages();
}
Concrete will:
– Create tables at **install** from `db.xml`.
– Apply **schema changes** at **upgrade** when you modify `db.xml`.
# 4) Evolving the schema (future changes)
When you need a new column or index:
1. Edit `db.xml` (e.g., add “).
2. Bump your package version (`$pkgVersion`).
3. Run upgrade (Dashboard → Extend Concrete → Update Add‑ons, or CLI).
No custom migration code needed for typical changes.
# 5) Foreign keys (optional)
Concrete’s `db.xml` doesn’t declare FKs directly. Typical patterns:
– Enforce in **application logic** (simplest).
– Or add an FK in a one‑time **migration** (optional) using Doctrine’s SchemaManager in `upgrade()`; keep it minimal and guarded by checks. Reviewers accept either approach—most marketplace add‑ons skip DB‑level FKs.
# 6) Seeding default rows (if you must)
Do **not** seed with raw SQL in the controller. Use:
– A tiny migration in `upgrade()` that checks if rows exist, then inserts via the QueryBuilder, **or**
– Add defaults at first‑use in your dashboard controller/service (often cleaner).
# 7) Uninstall policy (marketplace‑friendly)
– **Default**: keep data (preferred). Don’t drop tables in `uninstall()`.
– If you offer “purge everything,” make it a separate admin action or a clearly documented alternative method.
# 8) Test checklist
– Fresh install: tables appear.
– Upgrade path: start from an older version, modify `db.xml`, bump version, upgrade → new fields exist, data preserved.
– Uninstall: package pages removed; tables remain (or purged only if you implemented an explicit purge route).
—
If you’d like, I can generate the `db.xml` file and place it in your canvas, and also show a tiny guarded migration snippet for adding a foreign key the safe way.
# 1) Create Entities (PSR‑4 under your package)
Add classes under `packages/shopping_feed_manager/src/Entity`:
<?php
// packages/shopping_feed_manager/src/Entity/ShoppingFeedConfiguration.php
namespace ConcretePackageShoppingFeedManagerEntity;
use DoctrineORMMapping as ORM;
#[ORMEntity]
#[ORMTable(name: "shoppingfeedconfiguration")]
class ShoppingFeedConfiguration
{
#[ORMId, ORMGeneratedValue, ORMColumn(type: "integer")]
private int $id;
#[ORMColumn(type: "string", length: 255, unique: true)]
private string $url;
#[ORMColumn(type: "string", length: 255)]
private string $name;
#[ORMColumn(type: "string", length: 255)]
private string $feedSlug;
#[ORMColumn(type: "text", nullable: true)]
private ?string $settings = null;
#[ORMColumn(type: "datetime", nullable: true)]
private ?DateTimeInterface $created_at = null;
#[ORMColumn(type: "datetime", nullable: true)]
private ?DateTimeInterface $latestGeneration = null;
#[ORMColumn(type: "integer", options: ["default" => 0])]
private int $noOfProducts = 0;
#[ORMColumn(type: "datetime", nullable: true)]
private ?DateTimeInterface $updated_at = null;
#[ORMColumn(type: "integer", options: ["default" => 0])]
private int $enableCron = 0;
// getters/setters ...
}
<?php
// packages/shopping_feed_manager/src/Entity/ShoppingFeedLog.php
namespace ConcretePackageShoppingFeedManagerEntity;
use DoctrineORMMapping as ORM;
#[ORMEntity]
#[ORMTable(name: "shoppingfeedlog")]
class ShoppingFeedLog
{
#[ORMId, ORMGeneratedValue, ORMColumn(type: "integer")]
private int $id;
#[ORMManyToOne(targetEntity: ShoppingFeedConfiguration::class)]
#[ORMJoinColumn(name: "feed_id", referencedColumnName: "id", onDelete: "CASCADE", nullable: false)]
private ShoppingFeedConfiguration $feed;
#[ORMColumn(type: "datetime")]
private DateTimeInterface $updated_at;
#[ORMColumn(type: "string", length: 50, nullable: true)]
private ?string $status = null;
#[ORMColumn(type: "text", nullable: true)]
private ?string $error_message = null;
// getters/setters ...
}
> Using PHP 8 attributes. You can use Doctrine annotations if you prefer.
# 2) Tell Concrete where your entities live
In your **package controller**, add the entity namespace. Concrete will auto‑wire the ORM metadata from that namespace.
class Controller extends Package
{
// ...
/** @return string[] */
public function getEntityNamespaces()
{
return [
'ConcretePackageShoppingFeedManagerEntity',
];
}
}
# 3) Install/upgrade ORM schema
Call the ORM helpers during install/upgrade. Concrete exposes convenience methods:
public function install(): void
{
$pkg = parent::install();
// Register pages/tasks as usual...
$this->installEntities(); // <-- creates ORM tables for your entities
$this->addDashboardPages();
}
public function upgrade(): void
{
parent::upgrade();
$this->refreshEntities(); // <-- updates schema diffs for your entities
$this->addDashboardPages();
}
> If your Concrete build doesn’t have `installEntities()/refreshEntities()`, use Doctrine’s `SchemaTool` manually (shown at the end).
# 4) Use the EntityManager in your code
$em = $this->app->make('database/orm')->entityManager();
$config = new EntityShoppingFeedConfiguration();
$config->setName('My Feed');
// ...set fields...
$em->persist($config);
$em->flush();
// Query:
$repo = $em->getRepository(EntityShoppingFeedConfiguration::class);
$all = $repo->findBy([], ['id' => 'DESC']);
# 5) Composer autoload (important)
Ensure your package’s `composer.json` maps the namespace so Doctrine can load classes:
{
"type": "concrete5-package",
"autoload": {
"psr-4": {
"Concrete\Package\ShoppingFeedManager\": "src/"
}
}
}
Run `composer dump-autoload` during development. For marketplace zips, include `vendor/` only if you add **extra** libraries; Concrete itself ships Doctrine.
# 6) Uninstall policy
Marketplace‑friendly default: **keep data** on uninstall (do not drop ORM tables). If you need a “purge” path, expose an admin action that uses SchemaTool to drop your entity tables.
—
## Manual fallback (if `installEntities()` / `refreshEntities()` aren’t available)
You can drive Doctrine’s SchemaTool directly:
use DoctrineORMToolsSchemaTool;
public function install(): void
{
$pkg = parent::install();
$em = $this->app->make('database/orm')->entityManager();
$classes = [
$em->getClassMetadata(ConcretePackageShoppingFeedManagerEntityShoppingFeedConfiguration::class),
$em->getClassMetadata(ConcretePackageShoppingFeedManagerEntityShoppingFeedLog::class),
];
$tool = new SchemaTool($em);
$tool->updateSchema($classes, true); // create if not exists
}
public function upgrade(): void
{
parent::upgrade();
$em = $this->app->make('database/orm')->entityManager();
$classes = [
$em->getClassMetadata(ConcretePackageShoppingFeedManagerEntityShoppingFeedConfiguration::class),
$em->getClassMetadata(ConcretePackageShoppingFeedManagerEntityShoppingFeedLog::class),
];
(new SchemaTool($em))->updateSchema($classes, true); // safe diff
}
And for a **purge** action (not default uninstall):
public function uninstallPurgeAll(): void
{
$em = $this->app->make('database/orm')->entityManager();
$classes = [
$em->getClassMetadata(ConcretePackageShoppingFeedManagerEntityShoppingFeedLog::class),
$em->getClassMetadata(ConcretePackageShoppingFeedManagerEntityShoppingFeedConfiguration::class),
];
(new DoctrineORMToolsSchemaTool($em))->dropSchema($classes);
parent::uninstall();
}
—
### When to choose ORM vs `db.xml`
– **ORM** is great if you want rich domain models, relations, repositories, and you’ll query via EntityManager.
– **db.xml** is simpler if you just need tables and use the query builder/DBAL.
If you want, I can drop ready‑to‑use entity files + the controller edits into your canvas so you can copy/paste.
# Basics you’ll use everywhere
use ConcreteCoreSupportFacadeDatabase;
$db = Database::connection(); // DoctrineDBALConnection
$qb = $db->createQueryBuilder(); // DoctrineDBALQueryQueryBuilder
—
# CREATE
### shoppingfeedconfiguration (insert)
$id = $db->insert('shoppingfeedconfiguration', [
'url' => $url,
'name' => $name,
'feedSlug' => $slug,
'settings' => json_encode($settings ?? []),
'created_at' => (new DateTime())->format('Y-m-d H:i:s'),
'latestGeneration' => null,
'noOfProducts' => (int) $count,
'updated_at' => (new DateTime())->format('Y-m-d H:i:s'),
'enableCron' => (int) $enableCron,
]);
// New PK:
$newId = (int) $db->lastInsertId();
### shoppingfeedlog (insert)
$db->insert('shoppingfeedlog', [
'feed_id' => (int) $feedId,
'updated_at' => (new DateTime())->format('Y-m-d H:i:s'),
'status' => $status, // e.g., 'ok' | 'error'
'error_message' => $errorMessage ?? null,
]);
—
# READ
### One row by ID
$row = $db->fetchAssociative(
'SELECT * FROM shoppingfeedconfiguration WHERE id = ?',
[$id]
);
// $row is an array or false if not found
### One row by unique url
$row = $db->fetchAssociative(
'SELECT * FROM shoppingfeedconfiguration WHERE url = ?',
[$url]
);
### List + filters + pagination
$qb = $db->createQueryBuilder();
$qb->select('c.*')
->from('shoppingfeedconfiguration', 'c');
if (!empty($search)) {
$qb->andWhere('c.name LIKE :q OR c.feedSlug LIKE :q')
->setParameter('q', "%{$search}%");
}
$qb->orderBy('c.updated_at', 'DESC')
->setMaxResults($perPage) // e.g. 20
->setFirstResult($offset); // e.g. ($page-1)*$perPage
$rows = $qb->executeQuery()->fetchAllAssociative();
### Logs for a feed (latest first)
$rows = $db->fetchAllAssociative(
'SELECT * FROM shoppingfeedlog WHERE feed_id = ? ORDER BY updated_at DESC LIMIT 100',
[$feedId]
);
—
# UPDATE
$db->update('shoppingfeedconfiguration', [
'name' => $name,
'feedSlug' => $slug,
'settings' => json_encode($settings ?? []),
'noOfProducts' => (int) $count,
'updated_at' => (new DateTime())->format('Y-m-d H:i:s'),
'enableCron' => (int) $enableCron,
], [
'id' => (int) $id,
]);
—
# DELETE
// If you defined ON DELETE CASCADE (ORM) it would cascade; with db.xml you typically delete child rows first.
$db->beginTransaction();
try {
$db->delete('shoppingfeedlog', ['feed_id' => (int) $id]);
$db->delete('shoppingfeedconfiguration', ['id' => (int) $id]);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
—
# A tiny “Repository” you can reuse
Create `src/Repository/FeedRepository.php` in your package:
<?php
namespace ConcretePackageShoppingFeedManagerRepository;
use ConcreteCoreSupportFacadeDatabase;
class FeedRepository
{
private function db() { return Database::connection(); }
public function create(array $data): int
{
$now = (new DateTime())->format('Y-m-d H:i:s');
$payload = [
'url' => $data['url'],
'name' => $data['name'],
'feedSlug' => $data['feedSlug'],
'settings' => json_encode($data['settings'] ?? []),
'created_at' => $now,
'latestGeneration' => $data['latestGeneration'] ?? null,
'noOfProducts' => (int)($data['noOfProducts'] ?? 0),
'updated_at' => $now,
'enableCron' => (int)($data['enableCron'] ?? 0),
];
$this->db()->insert('shoppingfeedconfiguration', $payload);
return (int) $this->db()->lastInsertId();
}
public function find(int $id): ?array
{
$row = $this->db()->fetchAssociative('SELECT * FROM shoppingfeedconfiguration WHERE id = ?', [$id]);
return $row ?: null;
}
public function list(string $q = '', int $limit = 20, int $offset = 0): array
{
$qb = $this->db()->createQueryBuilder();
$qb->select('c.*')->from('shoppingfeedconfiguration', 'c');
if ($q !== '') {
$qb->andWhere('c.name LIKE :q OR c.feedSlug LIKE :q')
->setParameter('q', "%{$q}%");
}
$qb->orderBy('c.updated_at', 'DESC')->setMaxResults($limit)->setFirstResult($offset);
return $qb->executeQuery()->fetchAllAssociative();
}
public function update(int $id, array $data): void
{
$payload = array_filter([
'url' => $data['url'] ?? null,
'name' => $data['name'] ?? null,
'feedSlug' => $data['feedSlug'] ?? null,
'settings' => isset($data['settings']) ? json_encode($data['settings']) : null,
'noOfProducts' => isset($data['noOfProducts']) ? (int)$data['noOfProducts'] : null,
'enableCron' => isset($data['enableCron']) ? (int)$data['enableCron'] : null,
'updated_at' => (new DateTime())->format('Y-m-d H:i:s'),
], static fn($v) => $v !== null);
if ($payload) {
$this->db()->update('shoppingfeedconfiguration', $payload, ['id' => $id]);
}
}
public function delete(int $id): void
{
$db = $this->db();
$db->beginTransaction();
try {
$db->delete('shoppingfeedlog', ['feed_id' => $id]);
$db->delete('shoppingfeedconfiguration', ['id' => $id]);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
public function addLog(int $feedId, string $status, ?string $error = null): void
{
$this->db()->insert('shoppingfeedlog', [
'feed_id' => $feedId,
'updated_at' => (new DateTime())->format('Y-m-d H:i:s'),
'status' => $status,
'error_message' => $error,
]);
}
public function logs(int $feedId, int $limit = 100): array
{
return $this->db()->fetchAllAssociative(
'SELECT * FROM shoppingfeedlog WHERE feed_id = ? ORDER BY updated_at DESC LIMIT ?',
[$feedId, $limit],
[PDO::PARAM_INT, PDO::PARAM_INT]
);
}
}
Use it in your dashboard controller:
$repo = $this->app->make(ConcretePackageShoppingFeedManagerRepositoryFeedRepository::class);
$id = $repo->create(['url'=>$url,'name'=>$name,'feedSlug'=>$slug,'settings'=>$settings]);
$row = $repo->find($id);
$repo->update($id, ['name' => 'Updated']);
$repo->addLog($id, 'ok');
$repo->delete($id);
—
## Tips & guardrails
– **Always use parameters** (never concat into SQL) — all snippets above are parameterized.
– **Dates**: store as `Y-m-d H:i:s` strings (DBAL will handle them fine).
– **JSON**: encode/decode `settings` consistently.
– **Transactions**: wrap multi‑step writes (e.g., delete parent + children).
– **CSRF**: for dashboard POSTs, validate tokens with `Core::make('token')->validate('action')`.
If you want, I can drop the `FeedRepository.php` into your canvas ready to paste.
### Fix it fast
1) Open `/packages/shopping_feed_manager/db.xml` (package root, same level as `controller.php`) and make sure the very top looks **exactly** like this:
<?xml version="1.0" encoding="UTF-8"?>
<schema version="0.3">
<!-- your <table>… definitions here -->
</schema>
Common gotchas:
– Version must be **`0.3`** (not 0.1 / 0.5 / 1.0, and not missing).
– No extra attributes or XML namespaces on “.
– File must be **UTF‑8 without BOM** (a BOM or stray chars before “ can break parsing).
2) Quick checklist
– File path: `packages/shopping_feed_manager/db.xml` (not in `src/`).
– Root element is “ (lowercase), with a closing “.
– Valid field types only: `C` (varchar), `I` (int), `T` (datetime), `X` (text), `L` (boolean), `F` (float), etc.
3) Apply the change
– Bump `$pkgVersion` in your package controller (e.g., to `1.0.1`).
– **Dashboard → Extend Concrete → Update Add‑ons**, or CLI:
./vendor/bin/concrete5 c5:package:upgrade shopping_feed_manager
(On fresh installs: uninstall → install.)
### Minimal example template
<?xml version="1.0" encoding="UTF-8"?>
<schema version="0.3">
<table name="shoppingfeedconfiguration">
<field name="id" type="I" size="11" unsigned="true" autoincrement="true" primary="true"/>
<field name="url" type="C" size="255" notnull="true"/>
<field name="name" type="C" size="255" notnull="true"/>
<field name="feedSlug" type="C" size="255" notnull="true"/>
<field name="settings" type="X"/>
<field name="created_at" type="T"/>
<field name="latestGeneration" type="T"/>
<field name="noOfProducts" type="I" size="11" default="0"/>
<field name="updated_at" type="T"/>
<field name="enableCron" type="I" size="1" default="0"/>
<index name="idx_name"><col>name</col></index>
<index name="idx_url_unique" unique="true"><col>url</col></index>
</table>
<table name="shoppingfeedlog">
<field name="id" type="I" size="11" unsigned="true" autoincrement="true" primary="true"/>
<field name="feed_id" type="I" size="11" notnull="true"/>
<field name="updated_at" type="T" notnull="true"/>
<field name="status" type="C" size="50"/>
<field name="error_message" type="X"/>
<index name="idx_feed"><col>feed_id</col></index>
<index name="idx_status"><col>status</col></index>
<index name="idx_updated_at"><col>updated_at</col></index>
</table>
</schema>
If you still hit the error after this, paste the **first 3 lines** of your `db.xml` here — I’ll spot what’s off.
# Quick fix in `db.xml`
Inside “:
1) Keep the PK **only** on the id field:
<field name="id" type="I" size="11" unsigned="true" autoincrement="true" primary="true"/>
2) **Do not** add any `…` block.
Delete any index whose `name="primary"`.
3) Keep your *other* indexes, but give them non‑reserved names:
<index name="idx_name"><col>name</col></index>
<index name="idx_url_unique" unique="true"><col>url</col></index>
Do the same audit for `shoppingfeedlog`—make sure only the `id` field has `primary="true"` and there’s no `…`.
# Apply the change
– Bump `$pkgVersion` in your package controller.
– Run the package **upgrade** (Dashboard → Extend → Update Add‑ons) or via CLI.
# If you already installed a bad schema (and need to keep data)
1) Fix `db.xml` as above.
2) Upgrade may still fail if a duplicate “primary” index exists in the DB. Check and drop only the *extra* one:
**MySQL/MariaDB**
-- See existing indexes
SHOW INDEX FROM shoppingfeedconfiguration;
-- If there is a non‑PK index literally named 'primary' (rare), drop it:
ALTER TABLE shoppingfeedconfiguration DROP INDEX `primary`;
-- Do NOT drop the PRIMARY KEY. If needed, it’s named 'PRIMARY' (upper-case reserved).
-- You should keep the PRIMARY KEY on `id`.
3) Re‑run package **upgrade**.
# Common gotchas to avoid
– Only **one** field with `primary="true"` per table.
– Never create an “ with name `primary` (reserved).
– Index names must be unique **within the table** (e.g., don’t reuse `idx_name` twice).
– For unique constraints, use a different name (like `idx_url_unique`)—don’t try to make URL the primary key.
If you paste your current `db.xml` table block for `shoppingfeedconfiguration`, I’ll mark the exact line(s) to delete/rename.