Generating entry classes based on content types in the PHP Management SDK

The PHP SDK for the Content Management API supports a neat feature: code generation. With this, you can use the ResourceBuilder's extension mechanism to be able to provide a way to work with entry classes that are statically defined, which is perfect for use cases where you need to programmatically manage content, but content types don't change that often.

Generate entry classes based on content types

The SDK provides a CLI command which is executed like this:

php vendor/bin/contentful-management generate:entry-classes --access-token=$ACCESS_TOKEN --space-id=$SPACE_ID --environment-id=$ENVIRONMENT_ID --dir=$OUTPUT_DIR --namespace="App\\Entity"

This command will look up all available content types in the space you provide, and for each one of them, two classes will be generated: a resource class, and a mapper class. The first one is the one you will actually use, whereas the second one will be used internally for configuring the ResourceBuilder.

Let's use an example space to show how this works. It has two content types, author and blogPost:

Author
- name (Symbol)
- picture (Link to asset)

Blog Post
- title (Symbol)
- body (Text)
- author (Link to Author)
- publishedAt (Date)
- tags (array of Symbol)
- relatedPosts (array of Link to Blog Post)

After executing the command generate:entry-classes (using App as the value of the namespace parameter) on this space, you will have as a result the following files:

_loader.php
Author.php
BlogPost.php
Mapper/
    AuthorMapper.php
    BlogPostMapper.php

Let's take a look at _loader.php. In this file, you fill find some boilerplate code that is necessary to let the ResourceBuilder object know how to work with the generated classes. These will be the generated contents:

// You can include this file in your code or simply copy/paste it
// for configuring the active ResourceBuilder object
$builder->setDataMapperMatcher('Entry', function (array $data) {
    switch ($data['sys']['contentType']['sys']['id']) {
        case 'blogPost':
            return \App\Entity\Mapper\BlogPostMapper::class;
        case 'author':
            return \App\Entity\Mapper\AuthorMapper::class;
    }
});

As the comments say, you have two options. First, you can include this file somewhere in your code, making sure that you have in the current scope a variable called $builder:

use Contentful\Management\Client;

$client = new Client('<accessToken>');
$builder = $client->getBuilder();

require_once 'some-dir/_loader.php';

As an alternative, you can simply copy/paste the code: it's perfectly ok to do this, but remember to update the code should you execute the command again with different content types!

There are multiple advantages of generating classes compared to using the default Entry resource. If you're using a IDE, you will get autocompletion, and you'll be sure that you're working with valid field names. You'll also be able to properly type-hint entries based on their content types, instead of having to always use the default Contentful\Management\Resource\Entry class. And finally, you will get some extra niceties in the form of advanced field handling.

This means that the SDK will be able to work with PHP representations of fields, and also provide automatic link resolving. Let's go back to our previous example:

// $blogPost is an instance of your BlogPost class
$blogPost = $environmentProxy->getEntry('<blogPost_id>');

echo $blogPost->getTitle('en-US');
echo $blogPost->getBody('en-US');

// $date is an instance of Contentful\Core\Api\DateTimeImmutable
// which extends \DateTimeImmutable
$date = $blogPost->getPublishedAt('en-US');

// $author is an instance of Contentful\Core\Api\Link
$author = $blogPost->getAuthor('en-US');
// $author is an instance of your Author class
$author = $blogPost->resolveAuthorLink('en-US');

// $relatedPosts is an array of Contentful\Core\Api\Link objects
$relatedPosts = $blogPost->getRelatedPosts('en-US');
// $relatedPosts is an array of your BlogPost objects
$relatedPosts = $blogPost->resolveRelatedPostsLinks('en-US');

// You can perform all methods that work with regular Entry objects

// These methods are equivalent
$entry->setTitle('en-US', 'New title');
$entry->setField('title', 'en-US', 'New Title');

$blogPost->update();
$blogPost->delete();

// $author is an instance of your Author class
$author = $environmentProxy->getEntry('<author_id>');

echo $author->getName('en-US');

// $picture is an instance of Contentful\Core\Api\Link
$picture = $author->getPicture('en-US');
// $picture is an instance of Contentful\Management\Resource\Asset
$picture = $author->resolvePictureLink('en-US');

Of course, you can edit the generated classes to add convenience methods and customize them to your needs. Just remember that if you run the CLI command again, all files will be overwritten, so be careful when doing that.

More on extending the resource builder

The client gives you access to an instance of Contentful\Management\ResourceBuilder. This class is responsible for matching a raw resource (in the form of a PHP array) to the appropriate mapper (which implements Contentful\Core\ResourceBuilder\MapperInterface, or extends the base class Contentful\Management\Mapper\BaseMapper). A mapper is simply an object which provides a map method, which receives the data and possibly a PHP object whose properties should be overwritten.

The resource builder allows you to define a custom resource matcher, whose job will be to determine which mapper class to use based on the current data array. The code generation example above uses that in order to use a different mapper according to the content type, let's see it again:

use Contentful\Management\Client;

$client = new Client($accessToken);
$builder = $client->getBuilder();

$builder->setDataMapperMatcher('Entry', function (array $data) {
    switch ($data['sys']['contentType']['sys']['id']) {
        case 'blogPost':
            return \App\Entity\Mapper\BlogPostMapper::class;
        case 'author':
            return \App\Entity\Mapper\AuthorMapper::class;
    }
});

The first parameter of the setDataMapperMatcher method is the Contentful system type (available in sys.type); this is used so the custom matcher closure will only be executed when working on resources of that type.

The second parameter is an anonymous function, which receives the raw data array, and can have any custom logic in order to determine the correct mapper. In the example, it simply checks which content type is being used, and if it's one it recognizes, it will return the mapper FQCN. If the function does not return anything, the SDK will simply use the default value.

Now, let's take a look at a mapper class, to see how it's actually supposed to work. Let's see the BlogPostMapper from the previous example:

namespace App\Entity\Mapper;

use App\Entity\BlogPost;
use Contentful\Core\Api\DateTimeImmutable;
use Contentful\Core\Api\Link;
use Contentful\Management\Mapper\BaseMapper;
use Contentful\Management\SystemProperties;

/**
 * BlogPostMapper class.
 *
 * This class was autogenerated.
 */
class BlogPostMapper extends BaseMapper
{
    /**
     * {@inheritdoc}
     */
    public function map($resource, array $data): BlogPost
    {
        return $this->hydrate($resource ?? BlogPost::class, [
            'sys' => new SystemProperties($data['sys']),
            // Delegate the formatting of all fields
            'fields' => $this->formatFields($data['fields'] ?? []),
        ]);
    }

    /**
     * @param array $data
     *
     * @return array
     */
    private function formatFields(array $data): array
    {
        $fields = [];
        $fields['title'] = $data['title'] ?? null;
        $fields['body'] = $data['body'] ?? null;
        $fields['author'] = [];
        foreach ($data['author'] ?? [] as $locale => $value) {
            $fields['author'][$locale] = new Link($value['sys']['id'], $value['sys']['linkType']);
        }
        $fields['publishedAt'] = [];
        foreach ($data['publishedAt'] ?? [] as $locale => $value) {
            $fields['publishedAt'][$locale] = new DateTimeImmutable($value);
        }
        $fields['tags'] = $data['tags'] ?? null;
        $fields['relatedPosts'] = [];
        foreach ($data['relatedPosts'] ?? [] as $locale => $value) {
            $fields['relatedPosts'][$locale] = \array_map(function (array $link): Link {
                return new Link($link['sys']['id'], $link['sys']['linkType']);
            }, $value);
        }

        return $fields;
    }
}

The main focus here is the map method. In there, $this->hydrate is called: that's a convenience methods which accepts two parameters:

  • The first is either an object, or a FQCN. If a FQCN is given, an instance will be created (using Reflection, therefore without calling the constructor).
  • The second parameter is an associative array. The hydrate method will use magic and assign these properties to the object, without calling setter methods, and will work even if they are private or protected. This means that the keys in this array will be the name of the properties that the object will end up having assigned. It's very powerful, so use it with caution!

Finally, the SDK will glue everything together, and when trying to build a resource it will check whether there's a custom matcher assigned, and use the mapper returned from that matcher. The mapper will convert the data array to an actual object, and the process is over! Of course, the main use case for this is having custom entry classes according to their content types—which is what the CLI command provides. Nonetheless, you're free to use this mechanism however you please, and on which resource you want: entries, locales, webhooks, uploads... It is designed with flexibility in mind, so as to accommodate for all possible workflows.