Skip to content
TYPO3API Hub

Tutorial: your first action

In this tutorial, you add a report of recently changed pages to an extension. You write one class and one scope, and you get two HTTP endpoints, validation, error responses, access control and API documentation. We tested the code and the responses on TYPO3 14.3.10.

Define the scope

With a scope, administrators can give access to your actions separately. It appears in the app record and on the consent screen. If your action only reads content, use \TYPO3\CMS\Backend\Scope\ContentReadScope and skip this step.

EXT:my_extension/Classes/Scope/PageReportScope.php
php
namespace MyVendor\MyExtension\Scope;

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Scope\ScopeInterface;
use TYPO3\CMS\Core\Scope\ScopeUser;

#[AsTaggedItem(index: 'my-extension:report:read')]
final class PageReportScope implements ScopeInterface
{
    public function getName(): string
    {
        return 'Read page reports';
    }

    public function getDescription(): string
    {
        return 'Read reports about recently changed pages.';
    }

    public function getIcon(): string
    {
        return 'actions-document-info';
    }

    public function allowedForUser(ScopeUser $user): bool
    {
        return true;
    }
}

Write the action

Each public method with #[AsAction] becomes an action. The PHP types and the PHPDoc return type become the JSON Schema. With the default Configuration/Services.yaml of an extension, you register nothing.

EXT:my_extension/Classes/Action/PageReportAction.php
php
namespace MyVendor\MyExtension\Action;

use Doctrine\DBAL\ParameterType;
use MyVendor\MyExtension\Scope\PageReportScope;
use TYPO3\CMS\Core\Action\ActionContext;
use TYPO3\CMS\Core\Action\ActionType;
use TYPO3\CMS\Core\Action\Error\NotFoundError;
use TYPO3\CMS\Core\Attribute\AsAction;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Type\Bitmask\Permission;

final readonly class PageReportAction
{
    public function __construct(
        private ConnectionPool $connectionPool,
    ) {}

    /**
     * @param int $limit Maximum number of pages
     * @return list<array{
     *     uid: int,
     *     title: string,
     *     changedAt: int,
     * }>
     */
    #[AsAction(
        route: 'my-extension/pages/recent',
        type: ActionType::fetch,
        summary: 'List the recently changed pages',
        scopes: [PageReportScope::class],
    )]
    public function recent(
        ActionContext $context,
        int $limit = 5,
    ): array {
        $rows = $this->pagesVisibleTo($context)
            ->orderBy('tstamp', 'DESC')
            ->setMaxResults(max(1, min($limit, 50)))
            ->executeQuery()
            ->fetchAllAssociative();
        return array_map($this->toReport(...), $rows);
    }

    /**
     * @return array{uid: int, title: string, changedAt: int}
     * @throws NotFoundError The page is not available to the user
     */
    #[AsAction(
        route: 'my-extension/pages/{uid}',
        type: ActionType::fetch,
        summary: 'Fetch the report of one page',
        scopes: [PageReportScope::class],
    )]
    public function show(ActionContext $context, int $uid): array
    {
        $query = $this->pagesVisibleTo($context);
        $row = $query
            ->andWhere($query->expr()->eq(
                'uid',
                $query->createNamedParameter(
                    $uid,
                    ParameterType::INTEGER,
                ),
            ))
            ->executeQuery()
            ->fetchAssociative();
        if ($row === false) {
            throw new NotFoundError('Page not found', 1794000001);
        }
        return $this->toReport($row);
    }

    private function pagesVisibleTo(
        ActionContext $context,
    ): QueryBuilder {
        $user = $context->principal;
        assert($user instanceof BackendUserAuthentication);
        $visible = $user->getPagePermsClause(
            Permission::PAGE_SHOW,
        );
        $query = $this->connectionPool
            ->getQueryBuilderForTable('pages');
        return $query
            ->select('uid', 'title', 'tstamp')
            ->from('pages')
            ->where(
                QueryHelper::stripLogicalOperatorPrefix($visible),
            );
    }

    /**
     * @param array<string, mixed> $row
     * @return array{uid: int, title: string, changedAt: int}
     */
    private function toReport(array $row): array
    {
        return [
            'uid' => (int)$row['uid'],
            'title' => (string)$row['title'],
            'changedAt' => (int)$row['tstamp'],
        ];
    }
}

TYPO3 checks the scopes, but an action decides itself what its query returns. A direct database query has to apply the permissions of the user, as pagesVisibleTo() does here. APIs such as the DataHandler do this for you.

Clear the caches

TYPO3 compiles actions with the dependency injection container.

bash
vendor/bin/typo3 cache:flush

Grant the scope to an app

Open the app in Administration > Integrations > API Hub and select Read page reports.

Call the action

bash
curl -G -H "Authorization: Bearer $TYPO3_TOKEN" \
    "$TYPO3_URL/typo3/api/v1/my-extension/pages/recent" \
    -d limit=2
Response
json
[
    {
        "uid": 1,
        "title": "Alle sprog, alle tegn",
        "changedAt": 1793970942
    },
    {
        "uid": 2,
        "title": "Jede Sprache, jedes Zeichen",
        "changedAt": 1793970942
    }
]

Results without more code#

Validation

TYPO3 rejects a value of the wrong type before your method starts.

GET …/my-extension/pages/recent?limit=abc, status 400
json
{
    "type": "https://typo3.org/go/exception/CMS/1793870606",
    "title": "Bad Request",
    "status": 400,
    "detail": "The request contains invalid parameters",
    "errors": [
        {
            "parameter": "limit",
            "detail": "String value found, but an integer is required"
        }
    ]
}
Error responses

The exception code becomes the type of the response, so it is part of your API. Do not change it later.

GET …/my-extension/pages/999999, status 404
json
{
    "type": "https://typo3.org/go/exception/CMS/1794000001",
    "title": "Not Found",
    "status": 404,
    "detail": "Page not found"
}
Access control

Without the scope, TYPO3 does not call the method.

Status 403, after removing the scope from the app
json
{
    "type": "about:blank",
    "title": "Forbidden",
    "status": 403,
    "detail": "Missing scope: my-extension:report:read"
}
Documentation
The OpenAPI document now contains both operations, for example fetch_my_extension_pages_by_uid with the responses 200, 404 and default. The new scope is listed with the OAuth scopes.
Backend JavaScript

The same action is available in the backend, see Calling the API from backend JavaScript:

javascript
import { action } from '@typo3/core/action/request.js';

const request = action('/v1/my-extension/pages/{uid}', { uid: '3' });
const response = await request.get();
const report = await response.resolve();

Next steps#

  • Limit the channels of an action with targets.
  • Accept data from the path, query or body, see Parameter sources.
  • Add a new version without breaking clients, see Actions.
  • Declare further errors, see Errors.