Statamic Laravel Web development

Integrating Custom Data Sources in Statamic

Statamic ships with a powerful content management system out of the box. But what happens when you need to pull product data from an external API or project-specific records from your own database into the page builder?

In this guide, we walk through two approaches for integrating custom data sources into Statamic sections -- and explain why we only recommend one of them. Along the way, we make use of the Dictionary fieldset, View Components, our Action pattern, and a public example repository you can clone and explore.

Chris
Managing Director, Senior PHP Developer
Updated:
Statamic - Corporate Website.

What is this about?

Statamic manages content through flat files or Eloquent -- an elegant approach that covers most use cases perfectly well. In practice, however, we regularly encounter scenarios where we need to bring additional data sources into the page builder: products from an e-commerce system, listings from an internal database, job postings from a recruiting API -- the list goes on.

The question then becomes: how do we get this data into a Statamic section cleanly, without compromising the architecture?

There are essentially two approaches:

  1. Quick & Dirty -- Load the data directly in the Blade template. Fast to implement, but problematic in the long run.

  2. View Components -- Move the data logic into a Laravel View Component. Our recommended approach, because it keeps views free of business logic.

We will also look at how Statamic's relatively new Dictionary fieldset helps us offer selections from external data right inside the Control Panel -- without having to build a custom fieldtype. On top of that, we rely on our proven Action pattern and our Laramate Support Package to keep the implementation well-structured.

The Example Repository

All code examples in this guide come from our public example repository. You can follow along with the full implementation and use it as a starting point for your own projects:

github.com/Laramate/statamic-custom-data-example

The repo contains a Statamic installation with a page builder that loads product data from an external API. The API provides products and product categories -- a typical scenario that translates easily to many similar use cases.

The Dictionary Fieldset: External Data in the Control Panel

Before we render data on the front end, we need a way to let editors select from it inside the Statamic Control Panel. In our example, the editor should be able to pick a product category that the section will then filter by.

In the past, this would have required building a custom fieldtype -- complete with its own Vue component on the front end and a PHP class on the back end. That is a significant amount of work and, for many cases, simply overkill.

Statamic's Dictionary fieldtype offers a far more elegant solution: you define a PHP class that returns data from any source, and Statamic automatically renders it as a selection field in the Control Panel.

The Dictionary Class

Our ProductCategories class extends Statamic's BasicDictionary and specifies which field serves as the value and which as the label:

<?php

namespace App\Dictionaries;

use App\Actions\GetProductCategoriesAction;
use Statamic\Dictionaries\BasicDictionary;

class ProductCategories extends BasicDictionary
{
    protected string $valueKey = 'slug';
    protected string $labelKey = 'name';

    protected function getItems(): array
    {
        return new GetProductCategoriesAction()->handle();
    }
}Language:php

Notice that the Dictionary does not call the API directly -- instead, it delegates to an Action class. This brings tangible benefits:

  • Separation of concerns: The Dictionary handles presentation in the Control Panel; the Action handles data retrieval.

  • Reusability: The same Action can be used elsewhere in the project -- in a controller, an Artisan command, or a Livewire component.

  • Testability: The Action can be tested in isolation, without needing to boot the Dictionary or the Control Panel.

Wiring It Up in the Fieldset

In the page builder fieldset, we simply reference our Dictionary as the field type:

product_category:
  field:
    dictionary: product_categories
    placeholder: 'Filter a product category'
    clearable: true
    type: dictionary
    display: 'Product Categories'
    max_items: 1Language:yaml

The Control Panel now displays a dropdown populated with all product categories -- pulled from the API, without writing a single line of JavaScript.

Services and Actions: Clean API Communication

For communicating with the external API, we use a clear layered approach: a Service handles the HTTP connection and exposes methods for each API endpoint. Actions consume the Service to implement specific business logic.

For a deeper dive into our Action approach, check out our detailed post: Actions -- Bringing Order to Laravel Business Logic.

The Service

The ProductApiService encapsulates all HTTP communication. It configures the client once and provides dedicated methods for each endpoint:

<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

class ProductApiService
{
    public function __construct(
        public string $baseUrl = 'https://api.escuelajs.co/api/v1/'
    )
    {
        // Get your API Credentials from config
    }

    public function http(): PendingRequest
    {
        return Http::baseUrl($this->baseUrl)
            ->acceptJson()
            ->asJson();
    }

    public function getProducts(?string $category = null): Response
    {
        return $this->http()->get("/products?categorySlug={$category}");
    }

    public function getCategories(): Response
    {
        return $this->http()->get('/categories');
    }
}Language:php

In a production project, you would naturally pull API credentials from your config -- we are keeping things intentionally simple in this example.

The Actions

Our Actions extend the abstract Action class from the laramate/support package. Each Action has exactly one responsibility:

Fetching products:

<?php

namespace App\Actions;

use App\Services\ProductApiService;
use Laramate\Support\Tasks\Action;

class GetProductsAction extends Action
{
    public function __construct(public ?string $category = null) {}

    public function handle(): array
    {
        $productsResponse = new ProductApiService()->getProducts($this->category);

        return $productsResponse->json();
    }
}Language:php

Fetching product categories (with caching):

<?php

namespace App\Actions;

use App\Services\ProductApiService;
use Exception;
use Illuminate\Support\Facades\Cache;
use Laramate\Support\Tasks\Action;

class GetProductCategoriesAction extends Action
{
    public function handle(): array
    {
        if (Cache::has('product_categories')) {
            return Cache::get('product_categories');
        }

        $productCategoryResponse = new ProductApiService()->getCategories();

        if ($productCategoryResponse->successful()) {
            $categories = $productCategoryResponse->json();
            Cache::put('product_categories', $categories, 60);

            return $categories;
        }

        throw new Exception('Unable to query product categories');
    }
}Language:php

The GetProductCategoriesAction demonstrates an important pattern: categories are cached so that the Control Panel does not hit the API on every page load. The Dictionary consumes this Action -- and benefits from caching automatically.

Approach 1: Quick & Dirty -- Logic in the Template

The fastest way to get external data into a Statamic section is to query it directly in the Blade template. Essentially, you open a @php block and call the Action:

@php
    $products = new App\Actions\GetProductsAction($product_category)->handle();
@endphp

<section class="py-12 px-4 sm:px-6 lg:px-8 bg-gray-950 rounded-2xl">
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
        @foreach($products as $product)
            <x-product-card :product="$product"></x-product-card>
        @endforeach
    </div>
</section>Language:html

It works -- no doubt about that. But there are good reasons why we do not recommend this approach:

  • Logic in the template: Views should be responsible for presentation only. Once business logic creeps into a template, the codebase becomes harder to maintain and test.

  • Poor testability: A PHP block inside a Blade template cannot be tested in isolation. Any test would need to render the entire view.

  • No separation of concerns: Over time, more and more @php blocks accumulate, and templates become increasingly difficult to follow.

For a quick prototype or a one-off integration, this approach may be acceptable. For anything beyond that, there is a better way.

Laravel View Components are the ideal tool for keeping logic out of templates. The idea is straightforward: a PHP class prepares the data, and its corresponding Blade template handles nothing but the presentation.

The View Component

<?php

namespace App\View\Components;

use App\Actions\GetProductsAction;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;

class ProductListing extends Component
{
    public array $products;

    public function __construct(public string $category)
    {
        $this->products = new GetProductsAction($this->category)->handle();
    }

    public function render(): View|Closure|string
    {
        return view('components.product-listing', [
            'products' => $this->products,
        ]);
    }
}Language:php

The class receives the selected category, fetches the product data via the Action, and passes it to the template.

The Component Template

{{-- resources/views/components/product-listing.blade.php --}}
<section class="py-12 px-4 sm:px-6 lg:px-8 bg-gray-950 rounded-2xl">
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
        @foreach($products as $product)
            <x-product-card :product="$product"></x-product-card>
        @endforeach
    </div>
</section>Language:html

No @php block, no Action calls, no logic -- just clean HTML with Blade syntax.

The Page Builder Section

The Statamic page builder section template shrinks to a single line:

{{-- resources/views/page_builder/recommended_way.blade.php --}}
<x-product-listing :category="$product_category"></x-product-listing>Language:html

The $product_category variable comes straight from the Dictionary field that the editor populated in the Control Panel. The View Component takes care of the rest.

Side by Side: Why View Components Are the Better Choice

Let us compare the two approaches directly:

Quick & Dirty:

  • Mixes logic and presentation

  • Cannot be tested in isolation

  • Becomes unwieldy as the project grows

View Components:

  • Clean separation of logic and presentation

  • Testable in isolation

  • Reusable across different parts of the application

  • Built-in Laravel functionality -- no additional package required

Combined with the Action pattern and the Service layer, the result is an architecture where each layer has exactly one responsibility:

  1. Service -- HTTP communication with the API

  2. Action -- Business logic (e.g. caching, data transformation)

  3. View Component -- Data preparation for the view

  4. Blade Template -- Presentation only

The Page Builder at a Glance

In the page builder fieldset, we define both approaches as separate sections. This makes it easy to compare them side by side in the example repository:

title: 'Page Builder'
fields:
  -
    handle: sections
    field:
      type: replicator
      display: Sections
      sets:
        custom_data:
          display: 'Custom Data'
          sets:
            recommended_way:
              display: 'Recommended Way'
              fields:
                -
                  handle: product_category
                  field:
                    dictionary: product_categories
                    placeholder: 'Filter a product category'
                    clearable: true
                    type: dictionary
                    display: 'Product Categories'
                    max_items: 1
            quick_and_dirty:
              display: 'Quick And Dirty'
              fields:
                -
                  handle: product_category
                  field:
                    dictionary: product_categories
                    placeholder: 'Filter a product category'
                    clearable: true
                    type: dictionary
                    display: 'Product Categories'
                    max_items: 1Language:yaml

Both sections use the same Dictionary for category selection. The only difference lies in how the data is processed on the front end.

Wrapping Up

Integrating custom data sources into the Statamic page builder is not as daunting as it might seem. With the right tools -- the Dictionary fieldset, View Components, and the Action pattern -- the architecture stays clean and the codebase remains maintainable.

The Dictionary fieldset saves us the effort of building a custom fieldtype while still giving us the flexibility to surface any data source in the Control Panel. And with View Components, we ensure that templates do what they are meant to do: render, not compute.

Head over to the example repository to see it all in action. Clone it, install the dependencies, and start experimenting.