PHPackages                             enadstack/blogify - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. enadstack/blogify

ActiveLibrary

enadstack/blogify
=================

A headless, multi-tenant-aware content engine for Laravel SaaS products. Articles, taxonomy, media and SEO for both platform-level and per-tenant content, with first-class RTL/Arabic support.

v1.0.1(today)03↑2900%MITPHPPHP ^8.2

Since Jul 31Pushed todayCompare

[ Source](https://github.com/Enadabuzaid/blogify)[ Packagist](https://packagist.org/packages/enadstack/blogify)[ Docs](https://github.com/Enadabuzaid/blogify)[ RSS](/packages/enadstack-blogify/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (11)Versions (3)Used By (0)

Blogify
=======

[](#blogify)

A headless content engine for Laravel SaaS products. Articles, taxonomy, media and SEO, for both platform-level and per-tenant content, with first-class RTL/Arabic support.

**It ships no design.** No views, no routes, no controllers, no admin panel — your app owns rendering.

```
composer require enadstack/blogify
php artisan blogify:install
php artisan migrate
```

---

The problem it solves
---------------------

[](#the-problem-it-solves)

The same engine has to serve three shapes without forking:

ShapeWho owns a postA single site with a blognobody — every post is "the site's"A platform writing its own SEO contentthe platformEach tenant writing their ownthat doctor, lawyer, or clinicBlogify models this with **one nullable polymorphic owner**. `NULL` means platform-level. Anything else — a `Tenant`, a `User`, a `Clinic` — owns its own blog.

The owner columns exist in every mode, so **a single-tenant site can become multi-tenant later without a new migration**.

---

Tenancy
-------

[](#tenancy)

### Three modes

[](#three-modes)

```
'tenancy' => ['mode' => 'single'],   // single | shared | isolated
```

modescopingowner columns`single`nonealways `NULL``shared`one database, a global scope per ownerpopulated`isolated`none — the tenant database already isolatesstill populated`isolated` is for database-per-tenant setups. Publish the migrations into the tenant path:

```
php artisan vendor:publish --tag=blogify-migrations-tenant
```

### Resolvers

[](#resolvers)

Blogify takes **no dependency on any tenancy package**. Pick the resolver that matches how your app already works:

ResolverUse when`NullOwnerResolver`single-tenant, or database-per-tenant`ContainerOwnerResolver`a middleware binds the tenant: `app()->instance('currentTenant', $tenant)``AuthOwnerResolver`the owner hangs off the user: `$user->tenant`, `$user->lawyer``CallbackOwnerResolver`anything else — see below`StanclOwnerResolver``stancl/tenancy``SpatieOwnerResolver``spatie/laravel-multitenancy`The last two are guarded by `class_exists` and degrade to platform-level when the package is absent, so they are safe to reference either way.

For resolution logic none of them express — a public site that resolves by HTTP host, say — register a callback in a service provider:

```
use Enadstack\Blogify\Facades\Blogify;

Blogify::resolveOwnerUsing(fn () => app()->bound('currentTenant')
    ? app('currentTenant')
    : $this->tenantFromHost(request()->getHost()));
```

The callback lives on the class rather than in the config file **because a closure in config breaks `php artisan config:cache`**.

### Reading across owners

[](#reading-across-owners)

In `shared` mode every query is scoped to the resolved owner, strictly — a tenant's blog shows only their own posts, never the platform's. Escape hatches are explicit:

```
Post::query()->get();               // the current owner only
Post::query()->forOwner($tenant);   // one specific owner
Post::query()->platform();          // platform-level content
Post::query()->ownedByAnyone();     // every tenant, excluding the platform
Post::query()->allOwners();         // everything — admin and moderation views
```

Anything running outside a request — a queued job, a scheduled command — has no owner context, so **it must use `allOwners()`** or it will silently see only platform rows. `blogify:publish-scheduled` already does.

---

Content
-------

[](#content)

### Posts and translations

[](#posts-and-translations)

Locale-independent facts live on the post; everything else lives in a translation row.

```
$post = Post::create(['type' => 'post', 'status' => 'draft']);

$post->setTranslations([
    'en' => ['title' => 'Ten Tips for Better Sleep', 'body' => '...'],
    'ar' => ['title' => 'عشر نصائح لنوم أفضل', 'body' => '...'],
]);

$post->t('title', 'ar');   // 'عشر نصائح لنوم أفضل'
$post->slug('ar');         // 'عشر-نصائح-لنوم-أفضل'
```

Translations are **rows, not JSON columns**. A slug in a JSON column cannot be uniquely indexed and cannot be looked up through an index; one row per locale gives each language its own indexed slug, its own meta fields, and its own publish flag — so the Arabic can go live while the English is still being written.

### Slugs keep their script

[](#slugs-keep-their-script)

`Str::slug` forces ASCII, which mangles Arabic into unreadable transliteration and drops Hebrew and CJK entirely:

```
Str::slug('مرحبا بالعالم')  => 'mrhba-balaaalm'   mangled
Str::slug('שלום עולם')      => ''                 dropped

```

Blogify preserves Unicode letters instead, while still folding accents on Latin input:

```
Slugger::make('مرحبا بالعالم')  => 'مرحبا-بالعالم'
Slugger::make('Café Münster')   => 'cafe-munster'

```

Slugs are unique per owner and locale, so two tenants can both publish `about-us`.

### Taxonomy

[](#taxonomy)

One `blogify_terms` table serves every taxonomy, discriminated by a `taxonomy` column. Adding one is a config change, not a migration.

```
$category = Term::create(['taxonomy' => 'category']);
$category->setTranslation('ar', ['name' => 'قانون الأسرة']);

$post->terms()->attach($category);
```

Terms are owned like posts are, so the platform can define shared categories (`owner_key = '*'`) while each tenant adds their own.

### Media

[](#media)

A lean `blogify_media` table behind a `MediaAdapter` contract.

- `NativeMediaAdapter` (default) — writes to a Laravel disk. No dependencies.
- `SpatieMediaAdapter` — delegates to `spatie/laravel-medialibrary` if you already have it, reusing your conversions.

```
$media = app(MediaAdapter::class)->store($request->file('image'), [
    'attachable' => $post,
    'collection' => 'hero',
    'alt' => ['en' => 'A clinic room', 'ar' => 'غرفة في العيادة'],
]);
```

Alt text and captions are JSON rather than translation rows — unlike slugs they are never uniquely indexed or looked up, so a separate table would buy nothing.

---

SEO
---

[](#seo)

Everything returns arrays. Rendering is yours.

```
$url = fn (string $locale, string $slug) => route('blog.show', [$locale, $slug]);

Blogify::seo($post, 'ar', $url);      // title, description, canonical, robots, OG, Twitter, hreflang
Blogify::schema($post, 'ar', $url);   // JSON-LD, @type from the post's schema_type column
Blogify::sitemap($owner, $url);       // a Generator of sitemap entries
```

hreflang alternates come straight from the sibling translation rows — the payoff from storing translations as rows.

### Renaming a slug does not have to 404

[](#renaming-a-slug-does-not-have-to-404)

Every retired slug is recorded, so the old URL can redirect instead of breaking every inbound link:

```
if ($post = Blogify::resolveHistoricalSlug($slug, $locale)) {
    return redirect()->route('blog.show', [$locale, $post->slug($locale)], 301);
}
```

### Finding a post

[](#finding-a-post)

```
Blogify::findBySlug($slug, $locale);   // published, scoped to the current owner
```

A single-table indexed lookup with no join, because `owner_key` is denormalised onto the translations table.

---

Configuration worth knowing about
---------------------------------

[](#configuration-worth-knowing-about)

### `database.key_type` — decide before you migrate

[](#databasekey_type--decide-before-you-migrate)

```
'database' => ['key_type' => 'id'],   // id | ulid | uuid
```

Blogify's own tables can be keyed with bigints, ULIDs or UUIDs, to match the rest of your schema. **This is read by the migrations**, so it has to be set before `php artisan migrate` and cannot be changed afterwards without a manual migration. `blogify:install` asks.

Owner and author columns are always `string(40)` regardless, so a ULID-keyed tenant and a bigint-keyed user can both own content in the same table.

### `require_owner`

[](#require_owner)

```
'tenancy' => ['require_owner' => true],
```

In `shared` mode, throws rather than silently writing platform content when the resolver returns null. Useful for catching a missing middleware early. Leave it off if the platform also publishes.

### Everything else

[](#everything-else)

Table names, model classes, locales, RTL languages, post types, taxonomies, reading speed and the sitemap chunk size are all configurable. The published config explains each one.

---

Commands
--------

[](#commands)

CommandPurpose`blogify:install`Publish and configure `config/blogify.php`. Fully scriptable via options.`blogify:publish-scheduled`Promote scheduled posts whose date has arrived. Auto-scheduled via `blogify.schedule.publish_cron`.`blogify:sitemap`Write a sitemap for platform-level content.Per-tenant sitemaps need your URL scheme, which the package cannot know — build those from `Blogify::sitemap($owner, $urlBuilder)`.

Events
------

[](#events)

`PostPublished`, `PostUnpublished`, `PostDeleted` (with `$forced` distinguishing a soft delete), `TermCreated`.

---

Routing note
------------

[](#routing-note)

Blogify registers no routes. If you add your own, mind the ordering — a catch-all like this will swallow `/blog`:

```
Route::get('/{slug?}', [PageController::class, 'show'])->where('slug', '[a-z0-9-]+');
```

Register your blog routes **before** any such group.

Publish tags
------------

[](#publish-tags)

`blogify-config`, `blogify-migrations`, `blogify-migrations-tenant`, `blogify-translations`

Requirements
------------

[](#requirements)

PHP 8.2+, Laravel 11/12/13.

Testing
-------

[](#testing)

```
composer test      # Pest
composer analyse   # PHPStan
```

License
-------

[](#license)

MIT.

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance100

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

2

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/49005097?v=4)[Enad Abuzaid](/maintainers/Enadabuzaid)[@Enadabuzaid](https://github.com/Enadabuzaid)

---

Top Contributors

[![Enadabuzaid](https://avatars.githubusercontent.com/u/49005097?v=4)](https://github.com/Enadabuzaid "Enadabuzaid (2 commits)")

---

Tags

laravelcontentcmsheadlessblogseomulti-tenantarabicrtl

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/enadstack-blogify/health.svg)

```
[![Health](https://phpackages.com/badges/enadstack-blogify/health.svg)](https://phpackages.com/packages/enadstack-blogify)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3355.4M352](/packages/psalm-plugin-laravel)[laravel/ai

The official AI SDK for Laravel.

1.1k4.6M275](/packages/laravel-ai)[pressbooks/pressbooks

Pressbooks is an open source book publishing tool built on a WordPress multisite platform. Pressbooks outputs books in multiple formats, including PDF, EPUB, web, and a variety of XML flavours, using a theming/templating system, driven by CSS.

45844.8k1](/packages/pressbooks-pressbooks)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k16.3M144](/packages/laravel-pulse)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9872.4M142](/packages/roots-acorn)[illuminate/queue

The Illuminate Queue package.

20433.0M1.7k](/packages/illuminate-queue)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
