Concrete CMS Custom Package Development
A package in Concrete CMS is a container combining themes, blocks, attributes, page types, single pages, Express objects, and automation tasks. Properly structured package allows installing everything in one command and easily move solution between installations.
Package Structure
packages/my-package/
controller.php # main package controller
blocks/
feature-card/ # custom block
controller.php
db.xml
add.php
edit.php
view.php
themes/
my-theme/ # theme
attributes/
color_picker/ # custom attribute type
controller.php
single_pages/
dashboard/
my_package/
settings.php # Dashboard settings page
elements/
my_package/
settings_form.php
jobs/
sync_products.php # automated task (Cron Job)
config/
generated_overrides/
mail/
order_notification.php # email template
src/
Entity/ # Doctrine entities
Order.php
Repository/
OrderRepository.php
Service/
OrderService.php
db.xml # package common tables
controller.php — Package Heart
<?php
namespace Concrete\Package\MyPackage;
use Concrete\Core\Package\Package;
use Concrete\Core\Page\Single as SinglePage;
use Concrete\Core\Block\BlockType\BlockType;
use Concrete\Core\Page\Type\Type as PageType;
use Concrete\Core\Attribute\Type as AttributeType;
use Concrete\Core\Job\Job;
defined('C5_EXECUTE') or die('Access Denied.');
class Controller extends Package {
protected string $pkgHandle = 'my-package';
protected string $appVersionRequired = '9.0.0';
protected string $pkgVersion = '2.1.0';
public function getPackageName(): string { return t('My Package'); }
public function getPackageDescription(): string { return t('Full-featured package for corporate site'); }
public function on_start(): void {
// Service registration, autoloader, routes
$this->app->make(\Concrete\Package\MyPackage\Routing\RouteRegistrar::class)->register();
}
public function install(): void {
$pkg = parent::install();
$this->installOrUpgrade($pkg);
}
public function upgrade(): void {
parent::upgrade();
$pkg = $this->getPackageEntity();
$this->installOrUpgrade($pkg);
}
private function installOrUpgrade(\Concrete\Core\Entity\Package $pkg): void {
// Blocks
$this->installBlock('feature-card', $pkg);
$this->installBlock('team-member', $pkg);
$this->installBlock('testimonial', $pkg);
// Page types
$this->installPageType('service-detail', 'Service Detail', $pkg);
$this->installPageType('team-member', 'Team Member', $pkg);
// Page attributes
$this->installPageAttribute('hero_image', 'image', 'Hero Image', $pkg);
$this->installPageAttribute('intro_text', 'text', 'Intro Text', $pkg);
$this->installPageAttribute('meta_description', 'textarea', 'Meta Description', $pkg);
$this->installPageAttribute('show_in_nav', 'boolean', 'Show in Navigation', $pkg);
// Dashboard settings page
$sp = SinglePage::add('/dashboard/my_package', $pkg);
if ($sp) { $sp->update(['cName' => 'My Package', 'cDescription' => 'Settings']); }
$sp = SinglePage::add('/dashboard/my_package/settings', $pkg);
if ($sp) { $sp->update(['cName' => 'Settings']); }
// Cron Job
Job::installByPackage('sync_products', $pkg);
}
private function installBlock(string $handle, $pkg): void {
if (!\Concrete\Core\Block\BlockType\BlockType::getByHandle($handle)) {
BlockType::installBlockTypeFromPackage($handle, $pkg);
}
}
private function installPageType(string $handle, string $name, $pkg): void {
if (!PageType::getByHandle($handle)) {
PageType::add([
'ptHandle' => $handle,
'ptName' => $name,
'ptIsFrequentlyAdded' => 0,
'ptLaunchInComposer' => 1,
], $pkg);
}
}
private function installPageAttribute(string $handle, string $type, string $name, $pkg): void {
$at = AttributeType::getByHandle($type);
$ak = \Concrete\Core\Attribute\Key\CollectionKey::getByHandle($handle);
if (!$ak) {
\Concrete\Core\Attribute\Key\CollectionKey::add($at, [
'akHandle' => $handle,
'akName' => $name,
], $pkg);
}
}
public function uninstall(): void {
parent::uninstall();
}
}
Cron Job
<?php
// jobs/sync_products.php
namespace Concrete\Package\MyPackage\Job;
use Concrete\Core\Job\Job;
class SyncProducts extends Job {
public function getJobName(): string { return t('Sync Products'); }
public function getJobDescription(): string { return t('Sync products with external API'); }
public function run(): string {
$service = $this->app->make(\Concrete\Package\MyPackage\Service\ProductSyncService::class);
$count = $service->sync();
return t('Synced: %d products', $count);
}
}
Job runs via Dashboard → System → Automated Jobs or cron:
*/30 * * * * /usr/bin/php /var/www/mysite/concrete/bin/concrete5 c5:job:run sync_products
Dashboard Settings Page
<?php
// single_pages/dashboard/my_package/settings.php
class Settings extends DashboardPageController {
public function view(): void {
$config = $this->app->make('config');
$this->set('api_key', $config->get('my_package.api_key', ''));
$this->set('sync_interval', $config->get('my_package.sync_interval', 30));
}
public function save(): void {
$token = $this->app->make('token');
if (!$token->validate('my_package_settings')) {
$this->error->add(t('Invalid token'));
return $this->view();
}
$config = $this->app->make('config');
$config->save('my_package.api_key', $this->request->get('api_key'));
$config->save('my_package.sync_interval', (int)$this->request->get('sync_interval'));
$this->flash('success', t('Settings saved'));
$this->redirect('/dashboard/my_package/settings');
}
}
Package Development Timeline
| Component | Estimate |
|---|---|
| Package controller + installation | 4–8 h |
| 3–5 custom blocks | 3–6 days |
| Theme with 8–12 page types | 2–4 weeks |
| Doctrine entities + CRUD | 2–4 days |
| Dashboard page + settings | 1–2 days |
| REST API (3–5 endpoints) | 2–3 days |
| Cron Jobs (1–3 tasks) | 4–8 h |
| Full corporate package | 8–16 weeks |







