---
title: "Tutorial: your first action"
description: "Build your first action: a report of recently changed pages with its own scope and errors, and the result that TYPO3 generates."
canonical: tutorial.html
navigation-title: "Tutorial"
---

<a id="developer-tutorial"></a>

<a id="tutorial-your-first-action"></a>

# Tutorial: your first action

- [Results without more code](#results-without-more-code)
- [Next steps](#next-steps)

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.

1. **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;
       }
   }
   ```
2. **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'],
           ];
       }
   }
   ```

   > [!WARNING]
   > 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.
3. **Clear the caches**
   TYPO3 compiles actions with the dependency injection container.

   ```bash
   vendor/bin/typo3 cache:flush
   ```
4. **Grant the scope to an app**
   Open the app in Administration \> Integrations \> API Hub
   and select Read page reports.
5. **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
       }
   ]
   ```

<a id="developer-tutorial-generated"></a>

<a id="results-without-more-code"></a>

## 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](../usage/javascript.md#usage-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();
  ```

<a id="developer-tutorial-next"></a>

<a id="next-steps"></a>

## Next steps

- Limit the channels of an action with [targets](../developer/targets.md#developer-targets).
- Accept data from the path, query or body, see [Parameter sources](../developer/parameters.md#developer-parameters).
- Add a new version without breaking clients, see [Actions](../developer/actions.md#developer-actions).
- Declare further errors, see [Errors](../developer/errors.md#developer-errors).
