On the lookout of the simple content management solution with nice admin dashboard that has low barrier to enter I came across Cockpit CMS. This is an open-source project built in PHP and with clean structure that can be easily scanned when in need to jump into the code to understand something. This is important because it has really sparse documentation that acts as a mere introduction but is good enough to not get overwhelmed by the project. This aspect of it proved worthy when I dove deeper into basics.
My blog is powered by a Symfony application that uses a custom bundle for content management. I use it throughout a few of my websites. I started to be limited by not having a true admin dashboard where I could easily manage page properties and instead relied on MongoDB Compass. So first important bit is that a CMS should support MongoDB storage. Second one is that I store assets in S3. So a simple setting to use it by CMS would be great. This is the case with Cockpit CMS.
I followed the docs and chose Docker image. It can be simply customised to change default storage into MongoDB for data and S3 for assets. This is what I came with for Dockerfile
:
FROM agentejo/cockpit
ARG COCKPIT_VERSION="master"
ARG CLOUDSTORAGE="CloudStorage-${COCKPIT_VERSION}"
COPY config/config.yaml /var/www/html/config/
RUN wget https://github.com/agentejo/CloudStorage/archive/${COCKPIT_VERSION}.zip -O /tmp/cloudstorage.zip; unzip /tmp/cloudstorage.zip -d /tmp/; rm /tmp/cloudstorage.zip
RUN mkdir /var/www/html/addons/CloudStorage
RUN mv /tmp/${CLOUDSTORAGE}/* /var/www/html/addons/CloudStorage/
RUN rm -R /tmp/${CLOUDSTORAGE}/
RUN rm /var/www/html/config/config.php
It downloads the latest CloudStorage
addon for S3
support and copies config.yaml
that contains important S3 and MongoDB settings:
database:
server: your_server_url
options:
db: cockpitdb
cloudstorage:
assets:
type: s3
key: s3_user_key
secret: s3_user_secret_key
region: region
bucket: cockpitcms
To build it, I run docker build . -t cockpitcms/custom
from the directory that contains Dockerfile
and config/config.yaml
.
To run it locally, I run docker run -d --name cockpitcms-custom -p 8081:80 cockpitcms/custom
to serve it on localhost:8081
. That's it!
Now that I created a simple blog posts collection in Cockpit Admin and recreated my first page there, I could inspect its structure:
{
"title": "How I integrated Cockpit CMS with Symfony app",
"slug": "/blog/how-to-integrate-cockpit-cms-symfony-app",
"tags": "",
"approved": false,
"template": "",
"featured_image": "",
"excerpt": "My findings and encountered problems while playing with headless Cockpit CMS that acts as a data provider and backend for a Symfony application.",
"content": [
{
"component": "text_markdown",
"settings": {
"id": "",
"class": "",
"style": "",
"markdown": ""
}
}
],
"_modified": 1604415166,
"_created": 1604340864
}
I was particularly interested in layout
field type. My collection contains content
field of that type. On Collection's settings page
I could manually configure it to display custom options for components that the layout can consist of:
{
"components": {
"text_markdown": {
"fields": [
{
"name": "markdown",
"display": "Text Markdown",
"type": "markdown"
}
]
}
}
}
As you can see above, text_markdown
component has been added to my page's content
field along with any settings saved.
This extensive manual configuration for layout
fields makes them indefinitely useful! Another example is My Recent Articles component that I use on homepage and it is defined as simple as:
{
"components": {
"my_recent_articles": {
"label": "My Recent Articles"
}
}
}
inside of content
field's JSON config. The only important bit here is the component's key.
Now is the time to show how to use data produced in Cockpit CMS in the client Symfony app.
Because I configured Cockpit to save objects in MongoDB and my client Symfony app has it also configured as a backend db together with Doctrine MongoDB ODM, I created a simple MongoDB mapping of my Cockpit collection schema:
<?php
namespace AppBundle\CockpitCms\Document;
trait PageTrait
{
/**
* @MongoDB\Id
*/
public $id;
/**
* @MongoDB\Field(type="string")
*/
public $title;
/**
* @MongoDB\Field(type="string")
*/
public $slug;
/**
* @MongoDB\Field(type="int")
*/
public $_created;
/**
* @MongoDB\Field(type="int")
*/
public $_modified;
/**
* @MongoDB\Field(type="boolean")
*/
public $approved;
/**
* @MongoDB\Field(type="string")
*/
public $template;
/**
* @MongoDB\Field(type="string")
*/
public $excerpt;
/**
* @MongoDB\EmbedOne(targetDocument="AppBundle\CockpitCms\Document\Asset")
*/
public $featured_image;
}
I chose to define a trait that can potentially be reused by some other mapped collections. And now Asset
which is a concrete class and in my use case holds data of page's featured_image
:
<?php
namespace AppBundle\CockpitCms\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
/**
* @MongoDB\EmbeddedDocument
*/
class Asset
{
/**
* @MongoDB\Field(type="string")
*/
public $_id;
/**
* @MongoDB\Field(type="string")
*/
public $path;
/**
* @MongoDB\Field(type="string")
*/
public $title;
/**
* @MongoDB\Field(type="string")
*/
public $mime;
/**
* @MongoDB\Field(type="string")
*/
public $description;
/**
* @MongoDB\Field(type="int")
*/
public $width;
/**
* @MongoDB\Field(type="int")
*/
public $height;
/**
* @MongoDB\Field(type="collection")
*/
public $colors = [];
}
To complete the picture, this is the actual Page
:
<?php
namespace AppBundle\AdrianNowickiCom\Document;
use AppBundle\CockpitCms\Document\PageTrait;
use AppBundle\CockpitCms\Document\ContentComponent;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
/**
* @MongoDB\Document(
* db="cockpitdb",
* collection="collections_blogposts"
* )
*/
class Page {
use PageTrait;
/**
* @MongoDB\EmbedMany(targetDocument="AppBundle\CockpitCms\Document\ContentComponent")
*/
public $content;
}
You might have noticed that I intentionally keep my mapping classes thin.
When Doctrine is set up to connect to the same cluster as Cockpit CMS, then this is enough to start querying for that data like here in defaultAction
:
<?php
namespace AppBundle\AdrianNowickiCom;
use AppBundle\AdrianNowickiCom\Document\Page;
use AppBundle\CockpitCms\Document\ContentComponent;
use Doctrine\ODM\MongoDB\DocumentManager;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
/**
* @Route("/")
*/
class AdrianController extends Controller {
private $cmsComponentMapping;
public function __construct($cmsComponentMapping)
{
$this->cmsComponentMapping = $cmsComponentMapping;
}
/**
* @Route("{slug}", requirements={ "slug"=".*" })
*/
public function defaultAction(DocumentManager $dm, $slug)
{
$p = $dm->getRepository(Page::class)->findOneBy(['slug' => $slug]);
$template = $p->template != '' ? $p->template : 'oneColArticle';
return $this->render("@AdrianNowickiCom/$template.html.twig", [
'cmsContent' => $p->content,
'cmsComponentMapping' => $this->cmsComponentMapping,
'meta' => [
'featured_image' => [
'path' => $p->featured_image->path
],
'created_long' => date('Y-m-d H:i:s', $p->_created),
'created_short' => date('M j', $p->_created),
'description' => $p->excerpt,
'title' => $p->title
]
]);
}
}
Now comes the interesting part - cmsComponentMapping
. It's used in the template like this:
{% for component in cmsContent %}
{{ render(controller(cmsComponentMapping[component.component], { 'component': component })) }}
{% endfor %}
Remember those text_markdown
and my_recent_articles
components defined in Cockpit? The standard services.yml
file is the place to define their mappings to actual Symfony embeddable controllers so that the template above knows how to render them:
services:
_defaults:
autowire: true
autoconfigure: true
public: false
bind:
$cmsComponentMapping:
text_markdown: AppBundle\CockpitCms\CockpitCmsContentController:markdown
my_recent_articles: AppBundle\AdrianNowickiCom\AdrianController:myRecentArticles
Now you can switch between configurations/websites and have your content rendered differently as this additional layer of abstraction makes Cockpit not aware of client app implementation!
Content components are mapped on the Symfony side as ContentComponent
:
<?php
namespace AppBundle\CockpitCms\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
/**
* @MongoDB\EmbeddedDocument
*/
class ContentComponent
{
/**
* @MongoDB\Field(type="string")
*/
public $component;
/**
* @MongoDB\Field(type="hash")
*/
public $settings;
}
Let's take a look at simple text_markdown
Symfony renderer:
<?php
namespace AppBundle\CockpitCms;
use AppBundle\CockpitCms\Document\ContentComponent;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class CockpitCmsContentController extends Controller
{
/**
* @Template("@CockpitCms/markdown.html.twig")
*/
public function markdown(ContentComponent $component)
{
return [
'markdown' => $component->settings['markdown']
];
}
}
The controller is injected the instance of ContentComponent
and in this case makes use of markdown entered while editing a page in Cockpit. At the moment my markdown.html.twig
is as simple as:
{% markdown %}
{{ markdown|raw }}
{% endmarkdown %}
I wanted my Symfony integration with Cockpit CMS to be as bare-bones as possible and without too much coupling. My initial approach proved it is possible and the result is quite elegant.
When in Cockpit collection's entry, there's a beautiful feature to see all Linked collection entries. This does not work when I set up Cockpit to connect to MongoDB Atlas shared cluster since they don't allow Javascript to be executed on this plan and Linked feature uses Mongo's where
with JS :(