PHP code fixes
August 27, 2025 · AI Conversations
—
### 1) Imports (add at the top)
use PagerfantaPagerfanta;
use PagerfantaAdapterFixedAdapter;
use ConcreteCoreSearchPaginationPagination;
—
### 2) Fix the wrong helper call in `tracklog()`
Replace:
['page' => $page, 'perPage' => $perPage, 'offset' => $offset] = $this->getPagination();
with:
['page' => $page, 'perPage' => $perPage, 'offset' => $offset] = $this->getReqData();
—
### 3) Fix the `$total` query when `$keywords` is present (you forgot to execute)
Replace this block:
if($keywords){
$total = (int) $db->createQueryBuilder()
->select('COUNT(*)')
->from(self::T_CFG)
->where('name LIKE :keywords OR feedSlug LIKE :keywords OR url LIKE :keywords')
->setParameter('keywords', "%$keywords%");
} else {
$total = (int) $db->createQueryBuilder()
->select('COUNT(*)')
->from(self::T_CFG)
->execute()
->fetchOne();
}
with:
if ($keywords) {
$total = (int) $db->createQueryBuilder()
->select('COUNT(*)')
->from(self::T_CFG)
->where('name LIKE :keywords OR feedSlug LIKE :keywords OR url LIKE :keywords')
->setParameter('keywords', "%$keywords%")
->execute()
->fetchOne();
} else {
$total = (int) $db->createQueryBuilder()
->select('COUNT(*)')
->from(self::T_CFG)
->execute()
->fetchOne();
}
—
### 4) Replace the `PaginationFactory` usage (it expects a List provider, not an array)
**Remove**:
$factory = new PaginationFactory($this->app->make(Request::class));
$paginator = $factory->createPaginationObject($feeds);
// $pagination = $paginator->renderDefaultView();
// $this->set('pagination', $pagination);
// $this->set('paginator', $paginator);
**Add** (after you’ve got `$total`, `$feeds`, `$page`, `$perPage`):
$adapter = new FixedAdapter($total, $feeds);
$pagerfanta = new Pagerfanta($adapter);
$pagerfanta->setMaxPerPage($perPage);
$pagerfanta->setCurrentPage($page);
$pagination = new Pagination($this->request, $pagerfanta);
$this->set('pagination', $pagination);
Do the **same** in `tracklog()` after fetching `$total` and `$logs`:
$adapter = new FixedAdapter($total, $logs);
$pagerfanta = new Pagerfanta($adapter);
$pagerfanta->setMaxPerPage($perPage);
$pagerfanta->setCurrentPage($page);
$pagination = new Pagination($this->request, $pagerfanta);
$this->set('pagination', $pagination);
—
That’s it.
– The error came from passing an **array** to `PaginationFactory`, which expects a **List that implements Concrete’s pagination provider**.
– Switching to `Pagerfanta + FixedAdapter` creates a proper pagination object for arbitrary arrays.
– Also fixed the missing `execute()->fetchOne()` and the accidental call to `getPagination()`.
Here are the minimal changes:
### 1) Update `use` statements
use PagerfantaPagerfanta;
use PagerfantaAdapterFixedAdapter;
use PagerfantaViewDefaultView; // or TwitterBootstrap4View if you prefer
use ConcreteCoreSupportFacadeUrl;
### 2) After you build `$feeds` and have `$total`, `$page`, `$perPage`, replace your pagination code with this:
$adapter = new FixedAdapter($total, $feeds);
$pagerfanta = new Pagerfanta($adapter);
$pagerfanta->setMaxPerPage($perPage);
$pagerfanta->setCurrentPage($page);
// Build page links that preserve current query (keywords, limit)
$routeGenerator = function (int $p) use ($perPage, $keywords) {
$params = ['page' => $p, 'limit' => $perPage];
if ($keywords) {
$params['keywords'] = $keywords;
}
return (string) Url::to('/dashboard/shopping_feed') . '?' . http_build_query($params);
};
// Render HTML for the pager
$view = new DefaultView(); // or new TwitterBootstrap4View()
$pagerHtml = $view->render($pagerfanta, $routeGenerator, ['proximity' => 2]);
$this->set('feeds', $feeds);
$this->set('total', $total);
$this->set('page', $page);
$this->set('limit', $perPage);
$this->set('keywords', $keywords);
$this->set('pagination', $pagerHtml); // <-- pass HTML to your view
### 3) Do the same in `tracklog()` (swap `$feeds` → `$logs`, and URL path if your action is `tracklog`):
$adapter = new FixedAdapter($total, $logs);
$pagerfanta = new Pagerfanta($adapter);
$pagerfanta->setMaxPerPage($perPage);
$pagerfanta->setCurrentPage($page);
$routeGenerator = function (int $p) use ($perPage) {
$params = ['page' => $p, 'limit' => $perPage];
return (string) Url::to('/dashboard/shopping_feed/tracklog') . '?' . http_build_query($params);
};
$view = new DefaultView();
$pagerHtml = $view->render($pagerfanta, $routeGenerator, ['proximity' => 2]);
$this->set('pagination', $pagerHtml);
### 4) In your view (single page template)
Just echo the HTML:
<?php if (!empty($pagination)) { echo $pagination; } ?>
—
#### Why this works
– `ConcreteCoreSearchPaginationPagination` is meant for Concrete **ItemList** objects (`PageList`, `UserList`, etc.).
– You’re paginating arrays from custom SQL, so **Pagerfanta + FixedAdapter** is perfect, and using its own view avoids needing an ItemList.
If you later convert your queries into a proper `ItemList` subclass, you can go back to `$list->getPagination()` and Concrete’s pagination helper.
—
# 1) Create list classes
## `src/Search/ShoppingFeed/FeedList.php`
<?php
namespace ConcretePackageShoppingFeedManagerSearchShoppingFeed;
use ConcreteCoreSearchItemListDatabaseItemList;
use DoctrineDBALQueryQueryBuilder;
class FeedList extends ItemList
{
protected $autoSortColumns = ['id', 'name', 'feedSlug', 'url', 'created_at', 'updated_at'];
protected $itemsPerPage = 10;
private const T_CFG = 'ShoppingFeedConfiguration';
/** Build the base SELECT */
public function createQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('c.*')
->from(self::T_CFG, 'c');
$this->query = $qb;
}
/** For count(*) */
public function createCountQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('COUNT(*)')
->from(self::T_CFG, 'c');
return $qb;
}
/** Final tweaks before execution (sorting is handled by parent via sortBy/sortBySearchKey) */
public function finalizeQuery(QueryBuilder $qb)
{
// noop
}
/** Map a DB row to an item (array is fine) */
public function getResult($row)
{
return $row; // return as associative array
}
/** Convenience filter */
public function filterByKeywords(string $keywords): void
{
$kw = '%' . $keywords . '%';
$this->query->andWhere('c.name LIKE :kw OR c.feedSlug LIKE :kw OR c.url LIKE :kw')
->setParameter('kw', $kw);
}
}
## `src/Search/ShoppingFeed/LogList.php`
<?php
namespace ConcretePackageShoppingFeedManagerSearchShoppingFeed;
use ConcreteCoreSearchItemListDatabaseItemList;
use DoctrineDBALQueryQueryBuilder;
class LogList extends ItemList
{
protected $autoSortColumns = ['l.id', 'l.updated_at', 'l.status', 'c.name', 'c.feedSlug'];
protected $itemsPerPage = 10;
private const T_CFG = 'ShoppingFeedConfiguration';
private const T_LOG = 'ShoppingFeedLog';
public function createQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('l.id', 'l.feed_id', 'l.status', 'l.updated_at', 'l.error_message', 'c.name AS feed_name', 'c.feedSlug')
->from(self::T_LOG, 'l')
->leftJoin('l', self::T_CFG, 'c', 'c.id = l.feed_id');
$this->query = $qb;
}
public function createCountQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('COUNT(*)')
->from(self::T_LOG, 'l');
return $qb;
}
public function finalizeQuery(QueryBuilder $qb)
{
// noop
}
public function getResult($row)
{
return $row;
}
/** Optional keyword filter (search in log status/error and feed name/slug) */
public function filterByKeywords(string $keywords): void
{
$kw = '%' . $keywords . '%';
$this->query
->andWhere('l.status LIKE :kw OR l.error_message LIKE :kw OR c.name LIKE :kw OR c.feedSlug LIKE :kw')
->setParameter('kw', $kw);
}
}
> Make sure your package’s `composer.json` (or `controller.php` autoloader) maps `ConcretePackageShoppingFeedManager` to `packages/shopping_feed_manager/src/`.
—
# 2) Update your controller to use the lists
<?php
namespace ConcretePackageShoppingFeedManagerControllerSinglePageDashboard;
use ConcreteCorePageControllerDashboardPageController;
use ConcreteCoreHttpRequest;
use ConcreteCoreDatabaseConnectionConnection;
use ConcreteCoreHttpResponseFactoryInterface;
use DoctrineDBALException;
// ADD:
use ConcretePackageShoppingFeedManagerSearchShoppingFeedFeedList;
use ConcretePackageShoppingFeedManagerSearchShoppingFeedLogList;
class ShoppingFeed extends DashboardPageController
{
private function getReqData(): array
{
$req = $this->request;
$perPage = (int) $req->query->get('limit', 10);
$perPage = max(1, min($perPage, 100));
$page = max(1, (int) $req->query->get('page', 1));
$offset = ($page - 1) * $perPage;
return compact('page', 'perPage', 'offset');
}
public function view()
{
['page' => $page, 'perPage' => $perPage] = $this->getReqData();
$keywords = (string) $this->request->query->get('keywords', '');
try {
$list = new FeedList($this->app);
$list->sortBy('id', 'desc'); // default sort
if ($keywords !== '') {
$list->filterByKeywords($keywords);
}
$list->setItemsPerPage($perPage);
// Concrete default pagination
$pagination = $list->getPagination(); // reads ?page from Request
$feeds = $pagination->getCurrentPageResults();
$total = $pagination->getTotalResults();
$this->set('feeds', $feeds);
$this->set('total', $total);
$this->set('page', $page);
$this->set('limit', $perPage);
$this->set('keywords', $keywords);
$this->set('pagination', $pagination); // pass Pagination object to view (->renderDefaultView())
} catch (Exception $e) {
$this->error->add($e->getMessage());
}
}
public function tracklog()
{
['page' => $page, 'perPage' => $perPage] = $this->getReqData();
$keywords = (string) $this->request->query->get('keywords', '');
try {
$list = new LogList($this->app);
$list->sortBy('l.updated_at', 'desc');
if ($keywords !== '') {
$list->filterByKeywords($keywords);
}
$list->setItemsPerPage($perPage);
$pagination = $list->getPagination();
$logs = $pagination->getCurrentPageResults();
$total = $pagination->getTotalResults();
$this->set('logs', $logs);
$this->set('total', $total);
$this->set('page', $page);
$this->set('limit', $perPage);
$this->set('keywords', $keywords);
$this->set('pagination', $pagination);
} catch (Exception $e) {
$this->error->add($e->getMessage());
}
}
// deleteFeed() stays the same
}
—
# 3) In your view files
Concrete’s pagination object can render itself:
<?php if (isset($pagination)) : ?>
<div class="ccm-pagination">
<?php echo $pagination->renderDefaultView(); ?>
</div>
<?php endif; ?>
(Keep using `$feeds` / `$logs` arrays for rows.)
—
## Notes
– `DatabaseItemList` gives you `getPagination()` that integrates with Concrete’s default pager.
– Sorting: use `$list->sortBy('column', 'asc|desc')` for columns included in `$autoSortColumns`.
– Keyword filter: done via `filterByKeywords()`. Extend as needed.
– If your tables are actually snake_case, just change the constants in the list classes.
This converts your custom queries to **proper ItemLists**, so the default Concrete pagination “just works.”
Here’s the corrected **`FeedList`** and **`LogList`** with `getTotalResults()` implemented:
—
## `FeedList.php`
<?php
namespace ConcretePackageShoppingFeedManagerSearchShoppingFeed;
use ConcreteCoreSearchItemListDatabaseItemList;
use DoctrineDBALQueryQueryBuilder;
class FeedList extends ItemList
{
protected $autoSortColumns = ['id', 'name', 'feedSlug', 'url', 'created_at', 'updated_at'];
protected $itemsPerPage = 10;
private const T_CFG = 'ShoppingFeedConfiguration';
public function createQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('c.*')
->from(self::T_CFG, 'c');
$this->query = $qb;
}
public function createCountQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('COUNT(*)')
->from(self::T_CFG, 'c');
return $qb;
}
public function finalizeQuery(QueryBuilder $qb) {}
public function getResult($row)
{
return $row; // just return the associative array
}
public function filterByKeywords(string $keywords): void
{
$kw = '%' . $keywords . '%';
$this->query->andWhere('c.name LIKE :kw OR c.feedSlug LIKE :kw OR c.url LIKE :kw')
->setParameter('kw', $kw);
}
/** REQUIRED: total results */
public function getTotalResults()
{
$qb = $this->createCountQuery();
return (int) $qb->execute()->fetchOne();
}
}
—
## `LogList.php`
<?php
namespace ConcretePackageShoppingFeedManagerSearchShoppingFeed;
use ConcreteCoreSearchItemListDatabaseItemList;
use DoctrineDBALQueryQueryBuilder;
class LogList extends ItemList
{
protected $autoSortColumns = ['l.id', 'l.updated_at', 'l.status', 'c.name', 'c.feedSlug'];
protected $itemsPerPage = 10;
private const T_CFG = 'ShoppingFeedConfiguration';
private const T_LOG = 'ShoppingFeedLog';
public function createQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('l.id', 'l.feed_id', 'l.status', 'l.updated_at', 'l.error_message', 'c.name AS feed_name', 'c.feedSlug')
->from(self::T_LOG, 'l')
->leftJoin('l', self::T_CFG, 'c', 'c.id = l.feed_id');
$this->query = $qb;
}
public function createCountQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('COUNT(*)')
->from(self::T_LOG, 'l');
return $qb;
}
public function finalizeQuery(QueryBuilder $qb) {}
public function getResult($row)
{
return $row;
}
public function filterByKeywords(string $keywords): void
{
$kw = '%' . $keywords . '%';
$this->query
->andWhere('l.status LIKE :kw OR l.error_message LIKE :kw OR c.name LIKE :kw OR c.feedSlug LIKE :kw')
->setParameter('kw', $kw);
}
/** REQUIRED: total results */
public function getTotalResults()
{
$qb = $this->createCountQuery();
return (int) $qb->execute()->fetchOne();
}
}
—
✅ With `getTotalResults()` implemented, both lists will now satisfy the abstract contract of `ItemList`.
Now `$list->getPagination()` will return a proper Concrete pagination object you can render with:
<?php if (isset($pagination)) { echo $pagination->renderDefaultView(); } ?>
—
Do you want me to also show you how to **wire the CSRF token + deleteFeed into the list results view** (so you can delete feeds directly with Concrete’s dashboard UI helpers)?
Here are the exact changes:
# 1) Controller: pass `StickyRequest` (not `$this->app`)
Add import at the top:
use ConcreteCoreSearchStickyRequest;
Then change both places you create the lists:
**`view()`**
$list = new FeedList($this->app->make(StickyRequest::class)); // was new FeedList($this->app)
**`tracklog()`**
$list = new LogList($this->app->make(StickyRequest::class)); // was new LogList($this->app)
# 2) List classes: constructor + consistent filters on COUNT
(So they work regardless of Concrete version and your count reflects keyword filters.)
## FeedList.php (diff-only)
use ConcreteCoreSearchStickyRequest;
use ConcreteCoreSupportFacadeDatabase;
class FeedList extends ItemList
{
private const T_CFG = 'ShoppingFeedConfiguration';
private ?string $keywords = null;
public function __construct(StickyRequest $req = null)
{
parent::__construct($req);
$this->connection = Database::connection(); // ensure DB set
}
public function createQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('c.*')->from(self::T_CFG, 'c');
if ($this->keywords !== null) {
$qb->andWhere('c.name LIKE :kw OR c.feedSlug LIKE :kw OR c.url LIKE :kw')
->setParameter('kw', '%' . $this->keywords . '%');
}
$this->query = $qb;
}
public function createCountQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('COUNT(*)')->from(self::T_CFG, 'c');
if ($this->keywords !== null) {
$qb->andWhere('c.name LIKE :kw OR c.feedSlug LIKE :kw OR c.url LIKE :kw')
->setParameter('kw', '%' . $this->keywords . '%');
}
return $qb;
}
public function filterByKeywords(string $keywords): void
{
$this->keywords = $keywords;
// if query already built, also apply immediately:
if ($this->query) {
$this->query->andWhere('c.name LIKE :kw OR c.feedSlug LIKE :kw OR c.url LIKE :kw')
->setParameter('kw', '%' . $this->keywords . '%');
}
}
public function getTotalResults()
{
return (int) $this->createCountQuery()->execute()->fetchOne();
}
}
## LogList.php (diff-only)
use ConcreteCoreSearchStickyRequest;
use ConcreteCoreSupportFacadeDatabase;
class LogList extends ItemList
{
private const T_CFG = 'ShoppingFeedConfiguration';
private const T_LOG = 'ShoppingFeedLog';
private ?string $keywords = null;
public function __construct(StickyRequest $req = null)
{
parent::__construct($req);
$this->connection = Database::connection();
}
public function createQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('l.id','l.feed_id','l.status','l.updated_at','l.error_message','c.name AS feed_name','c.feedSlug')
->from(self::T_LOG, 'l')
->leftJoin('l', self::T_CFG, 'c', 'c.id = l.feed_id');
if ($this->keywords !== null) {
$qb->andWhere('l.status LIKE :kw OR l.error_message LIKE :kw OR c.name LIKE :kw OR c.feedSlug LIKE :kw')
->setParameter('kw', '%' . $this->keywords . '%');
}
$this->query = $qb;
}
public function createCountQuery()
{
$qb = $this->connection->createQueryBuilder();
$qb->select('COUNT(*)')->from(self::T_LOG, 'l');
if ($this->keywords !== null) {
$qb->andWhere('l.status LIKE :kw OR l.error_message LIKE :kw OR c.name LIKE :kw OR c.feedSlug LIKE :kw')
->setParameter('kw', '%' . $this->keywords . '%');
}
return $qb;
}
public function filterByKeywords(string $keywords): void
{
$this->keywords = $keywords;
if ($this->query) {
$this->query->andWhere('l.status LIKE :kw OR l.error_message LIKE :kw OR c.name LIKE :kw OR c.feedSlug LIKE :kw')
->setParameter('kw', '%' . $this->keywords . '%');
}
}
public function getTotalResults()
{
return (int) $this->createCountQuery()->execute()->fetchOne();
}
}
# 3) Keep the rest as-is
– `$pagination = $list->getPagination();`
– `$items = $pagination->getCurrentPageResults();`
– Render with `$pagination->renderDefaultView();`
This addresses the constructor type error and ensures pagination/count work with filters and default Concrete pagination.
—
## Option A — Use Concrete’s built-in pagination view (recommended)
Concrete’s `Pagination` already outputs BS5-compatible markup.
In your single page view (e.g., `shopping_feed.php`):
<?php if (isset($pagination) && $pagination->haveToPaginate()) : ?>
<nav aria-label="<?= t('Pagination') ?>">
<?= $pagination->renderDefaultView(); ?>
<?php // or: echo $pagination->renderView('dashboard'); ?>
</nav>
<?php endif; ?>
> `renderDefaultView()` in v9+ uses Bootstrap 5 styles (same look as other dashboard lists).
—
## Option B — Force the Pagerfanta Bootstrap 5 view explicitly
If you want to be explicit (or tweak proximity), render via Pagerfanta’s BS5 view:
**Add import at the top of your view file:**
<?php
use PagerfantaViewTwitterBootstrap5View;
?>
**Then render:**
<?php if (isset($pagination) && $pagination->haveToPaginate()) : ?>
<?php
$view = new TwitterBootstrap5View();
echo $view->render(
$pagination->getPagerfanta(), // Concrete wraps a Pagerfanta instance
$pagination->getRouteGenerator(), // Concrete’s route generator (preserves query params)
['proximity' => 2] // optional
);
?>
<?php endif; ?>
Both approaches give you Bootstrap 5 pagination consistent with other Concrete pages. Option A is simplest and “most Concrete,” Option B gives you a bit more control (proximity, custom view rendering) while still using the same URLs and state.