fiveam-code/laravel-notion-api
A Laravel wrapper for the Notion API that makes it easy to read and write pages, databases, blocks, and users from your app. It offers a fluent, Eloquent-like interface, handles authentication, and ships with helpful testing fakes.
Install and wire up (5 minutes)
composer require fiveam-code/laravel-notion-api
php artisan vendor:publish --provider="FiveamCode\LaravelNotionApi\NotionServiceProvider"
Add the integration token to .env:
NOTION_API_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Grab the token from https://www.notion.so/my-integrations. The package auto-discovers the service provider; if you're on a Laravel version without package discovery, register FiveamCode\LaravelNotionApi\NotionServiceProvider manually.
The step everyone skips: creating the integration is not enough. In Notion, open the target page/database → ⋯ → Connections → add your integration. Until you do, every call returns a 404 object_not_found, which looks identical to a bad ID. Do this before you debug anything.
First smoke test
// php artisan tinker
use FiveamCode\LaravelNotionApi\Notion;
Notion::users()->me(); // confirms the token works at all
Then read a database:
$pages = Notion::database('a1b2c3d4e5f6...')->query()->fetch();
$pages->each(fn ($page) => dump($page->getTitle()));
Where to look first in the source
| Path | Why |
|---|---|
config/notion-api.php |
Token, API version pin, any client options |
src/Notion.php |
The facade root — shows which endpoint groups exist (databases, pages, blocks, users, search) |
src/Endpoints/ |
Every method you can actually call; this is the real documentation |
src/Entities/ |
Page, Database, Block, User, and the Properties/ subclasses |
The README shows the happy path; src/Endpoints/DatabaseEndpoint.php and src/Entities/Properties/ are where you find out whether a specific Notion feature is supported. Budget 15 minutes there before assuming a capability is missing.
Best first real use case: a read-only sync of one small Notion database (content calendar, CRM lite, feature-flag table) into your app. It exercises auth, querying, pagination, and property mapping without any write risk — and write support is the part of this package that is most version-sensitive.
The dominant pattern is chain-then-fetch():
use FiveamCode\LaravelNotionApi\Notion;
$pages = Notion::database($databaseId)
->query()
->filterByCheckbox('Published', true)
->filterByTitle('Q3', 'contains')
->sortBy('Last edited time', 'descending')
->limit(50)
->fetch();
Wrap this in a repository class so your app never imports Notion directly:
final class ArticleRepository
{
public function published(int $limit = 50): Collection
{
return Cache::remember("notion.articles.$limit", now()->addMinutes(10), fn () =>
Notion::database(config('services.notion.articles_db'))
->query()
->filterByCheckbox('Published', true)
->sortBy('Published at', 'descending')
->limit($limit)
->fetch()
->map(fn ($page) => ArticleData::fromNotion($page))
);
}
}
That indirection is what makes the package testable — you assert on ArticleData, not on HTTP fixtures.
The raw Notion shapes are hostile to blade/API code. Convert at the boundary:
final class ArticleData
{
public static function fromNotion(Page $page): self
{
return new self(
id: $page->getId(),
title: $page->getTitle(),
slug: $page->getProperty('Slug')?->getPlainText(),
tags: $page->getProperty('Tags')?->getSelectedOptions() ?? [],
editedAt: Carbon::parse($page->getRawResponse()['last_edited_time']),
);
}
}
Use the typed property getters (getTitle(), getPlainText(), getNumber(), getSelectedOptions()) rather than indexing into $page['properties']. The typed getters are the package's main value-add; raw array walking will break the moment Notion adds a property type.
fetch() returns one page, max 100 results. That is not a bug and it will bite you in production:
$all = collect();
$query = Notion::database($id)->query()->limit(100);
do {
$batch = $query->fetch();
$all = $all->merge($batch);
} while ($query->hasNextPage() && $query->nextPage());
For anything over a few hundred rows, do this in a queued job that persists to Eloquent with updateOrCreate(['notion_id' => ...]) — never in a web request.
Notion has no reliable change webhook for database rows in this API generation. Poll on a schedule using last_edited_time:
// app/Console/Kernel.php
$schedule->job(new SyncNotionArticles)->everyFiveMinutes();
Persist a last_synced_at cursor, filter Last edited time on_or_after the cursor, and upsert keyed on the Notion ID. This turns an O(n) crawl into an O(changes) query.
Notion averages ~3 requests/second per integration. Two practical options:
// Job middleware — the clean way
public function middleware(): array
{
return [(new RateLimited('notion'))->releaseAfter(1)];
}
with RateLimiter::for('notion', fn () => Limit::perSecond(3)) in a service provider. Or, for quick scripts, usleep(340_000) between calls. A 429 response carries Retry-After — parse it and back off, don't retry immediately.
use FiveamCode\LaravelNotionApi\Entities\Page;
$page = new Page();
$page->setTitle('New article');
Notion::pages()->createInDatabase($databaseId, $page);
For writes you need insert capability on the integration (set at creation time in the Notion UI — you cannot upgrade an existing integration's capabilities in place; recreate it). Do write-path development against a throwaway sandbox database, because a mis-shaped property silently creates a row with an empty field rather than erroring.
Mock at your repository boundary first. If you need to test the package layer itself, check which HTTP client the SDK uses (composer show fiveam-code/notion-sdk-php) — if it's Laravel's, Http::fake(['api.notion.com/*' => Http::response($fixture)]) works cleanly; if it's Guzzle directly, you'll need a MockHandler stack, which is more painful and usually not worth it. Committing a handful of real JSON fixtures captured from getRawResponse() and faking at the HTTP layer gives the best cost/benefit.
404 object_not_found is almost always permissions. The integration must be explicitly connected to each page or database. Renaming, moving, or duplicating a database in Notion drops the connection. This one failure mode accounts for most first-day frustration.
Pagination is silent. Getting 100 rows when the database has 400 looks like the package "missing data." Confirm hasNextPage() before you file a bug.
Property type coverage lags Notion's API. Check src/Entities/Properties/ before designing around a property. status, formula, rollup, files, and people have historically been thin. When a getter is missing, drop to the raw payload:
$raw = $page->getProperty('Due')?->getRawContent(); // or getRawResponse()['properties']
That's the escape hatch — always check it exists before concluding the package can't do something.
The API version is pinned in config. Notion versions its API via a Notion-Version header, and this package ships a default. If Notion deprecates it you'll get 400s with a version message; override NOTION_VERSION (or the config key) rather than forking. Don't bump it blindly — response shapes for new versions can break the entity mappers.
Titles and rich text are arrays, not strings. getTitle() returns the flattened text, but the underlying value is an array of rich-text segments with annotations. If you need links or bold runs, read the segments; if you just need text, always use the helper. Never $page['properties']['Name']['title'][0]['plain_text'].
Dates are ISO-8601 strings in UTC. Carbon::parse(...)->setTimezone(config('app.timezone')) for display. Notion's date property can also be a range ({start, end}) — check for end before assuming a point in time.
Config cache and queue workers. After changing .env, run php artisan config:cache and php artisan queue:restart. A worker holding an old token produces confusing 401s.
IDs are dash-insensitive. Notion accepts both a1b2c3d4-... and a1b2c3d4...; the package and Notion normalize differently. Never string-compare IDs — strip dashes before comparing or storing in a unique index.
Empty is not null. A query returning zero rows gives an empty Collection. ->first() gives null and a subsequent ->getTitle() is a 500 in production. Guard explicitly or use ->firstOr(fn () => ArticleData::empty()).
Extension point: bind your own client. If you need request logging, retry semantics, or a tracing header, bind a configured client instance into the container in a service provider and let the facade resolve it — cleaner than sprinkling Log::info around call sites. The Notion facade resolving through the container is the intended seam.
Overall day-to-day verdict. For read-heavy, small-to-medium integrations (a few hundred rows, one-way sync, admin dashboards), this package is a genuine time-saver: the query builder and typed property getters eliminate a lot of boilerplate. For write-heavy or schema-fragile work, budget time for verifying property support against src/Entities/Properties/ and keep a raw-payload fallback in your mapper. Stars (436) and a March 2025 release suggest a maintained-but-niche project — pin the version in composer.json, and isolate it behind one repository class so swapping to the raw SDK later is a one-file change.
How can I help you explore Laravel packages today?