Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Contentful: Loading Data from an External CMS

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

If we added five more entities and we wanted to be able to select those as items in the Layouts admin, we could add five more value types, query types, and item views. Now that we know what we're doing, it's a pretty quick process and would give us a lot of power on our site.

But one of the beautiful things about Layouts is that our value types can come from anywhere: a Doctrine Entity, data on an external API, data in a Sylius store or from Ibexa CMS. In fact, systems like Sylius and Ibexa already have packages that do all of the work of integrating and adding the value types for you.

One of the biggest missing pieces on our site is the skills. The skills on the homepage are hard-coded and the "All Skills" link doesn't even go anywhere! We could have chosen to store these skills locally via another Doctrine Entity. But instead, we're going to load them from an external API via a service called "Contentful".

Hello Contentful!

I'll head over to Contentful.com and log in. This takes me to a "Contentful" space called "Bark & Bake" that I've already created. Contentful is awesome! It's basically a CMS as a service. It allows us to create different types of content called "content models". Right now, I have a content model called "Skill" and another one called "Advertisement". If we clicked on these, we could enter content via a super-friendly interface. I've already created 5 skills, each with a bunch of data.

So, you create and maintain your content here. Then Contentful has a restful API that we can use to fetch all of this.

Contentful is cool. But the point of this isn't to teach you about Contentful. Nope! It's to show you how we could grab content for Layouts from anywhere. For example, if we want to load "skills" from Contentful, we could manually create a new value type and do all the work that we did before, except making API requests to Contentful instead of querying the database.

But! We don't even need to do that! Why? Because Layouts already has a bundle that supports Contentful. That bundle add the value type, some query types, the item views and even the content browser integration for us. Woh.

Let's grab it!

Installing the Contentful Bundle

Spin over to your terminal and run:

composer require netgen/layouts-contentful -W

The -W is there just because, at least when recording this, Composer needs to be able to downgrade one small package to make all the dependencies happy. That flag allows it to do that.

Ok! The recipe for this package added a new config file: config/packages/contentful.yaml:

# For the complete configuration, please visit
# https://www.contentful.com/developers/docs/php/tutorials/getting-started-with-contentful-and-symfony/
space: "%env(CONTENTFUL_SPACE_ID)%"

And this reads two new environment variables... which live in .env:

35 lines .env
... lines 1 - 30
###> contentful/contentful-bundle ###

While we're here, let's update these values to point at my Contentful space. Copy the keys from the code block on this page and paste them here. Here's my CONTENTFUL_SPACE_ID... and my CONTENTFUL_ACCESS_TOKEN, which will give us read access to my space:

35 lines .env
... lines 1 - 30
###> contentful/contentful-bundle ###

Contentful + Layouts

Okay, the Layouts + Contentful integration give us two very separate things, and it's super important to understand the difference to keep everything clear.

First, the package adds an integration between Layouts and Contentful. This means it adds new value types, new query types, and all the other stuff we just added for Doctrine. In other words, we can instantly add the skills or advertisements from Contentful into list or grid blocks. That is great, and we'll see it soon.

The second thing the Contentful integration adds is completely unrelated to Layouts. It's dynamic routes. It adds a system so that every "item" in Contentful is available via its own URL. Literally, all of these skills will instantly have their own page on our site. This has nothing to do with Layouts, which is all about controlling the layout for existing pages on your site, not adding new pages.

Setting up the Dynamic Routing

But, since Contentful is a CMS, it is nice to have a page for each piece of content. To get the dynamic routes working, go into the config/packages/ directory and add a new file called cmf_routing.yaml. CMF Routing is a package that Contentful uses behind the scenes to add the dynamic routes. I'll paste some config here:

router.default: 200
cmf_routing.dynamic_router: 100
default_controller: netgen_layouts.contentful.controller.view
enabled: true

It's ugly... but this part doesn't have anything to do with Layouts, so it doesn't matter too much. This is all about allowing Contentful to automatically add dynamic URLs to our site.

This routing system stores routes in the database... and that means we need some new database. Head over to your console and run:

symfony console make:migration

And... I get an error. Rude. Let's try clearing our cache... maybe something weird happened... or it didn't see my new config file yet.

php bin/console cache:clear

Once the cache clears... I'll make the migration again:

symfony console make:migration

This time... perfect! Open the migrations/ directory, find that file and... it looks good!

... lines 1 - 12
final class Version20221024142326 extends AbstractMigration
public function getDescription(): string
return '';
public function up(Schema $schema): void
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE contentful_entry (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, json LONGTEXT NOT NULL, is_published TINYINT(1) NOT NULL, is_deleted TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE contentful_entry_route (contentful_entry_id VARCHAR(255) NOT NULL, route_id INT NOT NULL, INDEX IDX_58B6BC6E877C153C (contentful_entry_id), INDEX IDX_58B6BC6E34ECB4E6 (route_id), PRIMARY KEY(contentful_entry_id, route_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE orm_redirects (id INT AUTO_INCREMENT NOT NULL, host VARCHAR(255) NOT NULL, schemes LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', methods LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', defaults LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', requirements LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', options LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', condition_expr VARCHAR(255) DEFAULT NULL, variable_pattern VARCHAR(255) DEFAULT NULL, staticPrefix VARCHAR(255) DEFAULT NULL, routeName VARCHAR(255) NOT NULL, uri VARCHAR(255) DEFAULT NULL, permanent TINYINT(1) NOT NULL, routeTargetId INT DEFAULT NULL, UNIQUE INDEX UNIQ_6CA17E0391F30BA8 (routeName), INDEX IDX_6CA17E034C0848C6 (routeTargetId), INDEX IDX_6CA17E03A5B5867E (staticPrefix), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE orm_routes (id INT AUTO_INCREMENT NOT NULL, host VARCHAR(255) NOT NULL, schemes LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', methods LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', defaults LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', requirements LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', options LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', condition_expr VARCHAR(255) DEFAULT NULL, variable_pattern VARCHAR(255) DEFAULT NULL, staticPrefix VARCHAR(255) DEFAULT NULL, name VARCHAR(255) NOT NULL, position INT NOT NULL, INDEX IDX_5793FCA5B5867E (staticPrefix), UNIQUE INDEX name_idx (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE contentful_entry_route ADD CONSTRAINT FK_58B6BC6E877C153C FOREIGN KEY (contentful_entry_id) REFERENCES contentful_entry (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE contentful_entry_route ADD CONSTRAINT FK_58B6BC6E34ECB4E6 FOREIGN KEY (route_id) REFERENCES orm_routes (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE orm_redirects ADD CONSTRAINT FK_6CA17E034C0848C6 FOREIGN KEY (routeTargetId) REFERENCES orm_routes (id)');
... lines 31 - 42

We have a few tables that hold info about our Contentful data... and a few to store those dynamic routes.

Now run:

symfony console doctrine:migrations:migrate

And... woohoo! We have the new tables we need.

Finally, we can run a command to load all of our content from Contentful and create those dynamic routes. Once again, this is functionality that has nothing to do with Layouts. Run:

symfony console contentful:sync

And... beautiful! It loaded six items. On production you can set up a webhook so your site is instantly synced with any changes that you make on Contentful. But while we're developing, running this command works fine.

The result of this command is that every piece of content on Contentful now has its own page! To see them, run:

symfony console contentful:routes

And... awesome! Apparently I have a URL called /mashing. Let's go check it out! Go back to our site, navigate to /mashing and... it works! Sort of. It's here, but the middle of it is empty.

Let's talk about what's going on next and how we can leverage Layouts to bring this page to life.

Leave a comment!

Login or Register to join the conversation

For some reason, I keep getting this message.

An exception has been thrown during the rendering of a template ("An exception occurred while executing a query: SQLSTATE[42S02]: Base table or view not found: 1146 Table 'app.nglayouts_rule_group' doesn't exist").

Looks like some of the tables are not created with bin/console make:migration


Hey Julien!

Sorry for the very slow reply - this one somehow slipped past me in the lead up to the holidays.

Ok, let's see. That nglayouts_rule_group table comes from Layouts' own migrations system - which comes ALL the way form this code block: https://symfonycasts.com/screencast/netgen-layouts/install#running-the-migrations

But, I think I see the problem. This is NOT something we have mentioned in the README.md file. I mean, if you are using the start/ directory, then it shouldn't be there, as these migrations aren't there in the start (but you would quickly see and run them while following the tutorial). But this command SHOULD be there in the finish/ directory, and I see that it's missing. I'm going to add that right now. Let me know if that's what tripped you up and if running this command fixes things :).


1 Reply

Hey Ryan,

I have run the command from your code block, and it did solve the problem. I think I might have dropped the database at some point and got very confused as to why the migrations weren't rebuilding the database correctly. I think it's unfortunate that regular migration doesn't do that job automatically.

Any way,

Thanks for your reply.




Sweet! Thanks for letting me know! It's also unfortunate that there are these "extra" migrations to worry about from Layouts, but I get why it's done that way :).


Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.7", // v3.7.0
        "doctrine/doctrine-bundle": "^2.7", // 2.7.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.13", // 2.13.3
        "easycorp/easyadmin-bundle": "^4.4", // v4.4.1
        "netgen/layouts-contentful": "^1.3", // 1.3.2
        "netgen/layouts-standard": "^1.3", // 1.3.1
        "pagerfanta/doctrine-orm-adapter": "^3.6",
        "sensio/framework-extra-bundle": "^6.2", // v6.2.8
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
        "symfony/console": "5.4.*", // v5.4.14
        "symfony/dotenv": "5.4.*", // v5.4.5
        "symfony/flex": "^1.17|^2", // v2.2.3
        "symfony/framework-bundle": "5.4.*", // v5.4.14
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "5.4.*", // v5.4.6
        "symfony/runtime": "5.4.*", // v5.4.11
        "symfony/security-bundle": "5.4.*", // v5.4.11
        "symfony/twig-bundle": "5.4.*", // v5.4.8
        "symfony/ux-live-component": "^2.x-dev", // 2.x-dev
        "symfony/ux-twig-component": "^2.x-dev", // 2.x-dev
        "symfony/validator": "5.4.*", // v5.4.14
        "symfony/webpack-encore-bundle": "^1.15", // v1.16.0
        "symfony/yaml": "5.4.*", // v5.4.14
        "twig/extra-bundle": "^2.12|^3.0", // v3.4.0
        "twig/twig": "^2.12|^3.0" // v3.4.3
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "5.4.*", // v5.4.11
        "symfony/maker-bundle": "^1.47", // v1.47.0
        "symfony/stopwatch": "5.4.*", // v5.4.13
        "symfony/web-profiler-bundle": "5.4.*", // v5.4.14
        "zenstruck/foundry": "^1.22" // v1.22.1