Introduction

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.

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.

Setup

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!

Getting familiar with Cockpit collections

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

Cockpit CMS layout field JSON options Cockpit CMS layout field JSON options

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.

Integration with 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.

Controller action

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 %}

Final Thoughts

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.

MongoDB Atlas shared cluster gotcha

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 :(