NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Posts for Laravel

Configuration

Every key has a default and the common ones are env-backed, so the package works without a published config. The published config/posts.php in full:

return [
    'model' => RoundlyConsulting\Posts\Models\Post::class,

    'key_type' => env('POSTS_KEY_TYPE', 'bigint'),                 // your author model's key
    'primary_key_type' => env('POSTS_PRIMARY_KEY_TYPE', 'bigint'), // the posts tables' own ids

    'tables' => [
        'posts' => 'posts',
        'categories' => 'post_categories',
        'category_post' => 'category_post',
        'tags' => 'post_tags',
        'tag_post' => 'post_tag',
    ],

    'author' => [
        'morph-name' => 'author',
        'nullable' => true,
    ],

    'locales' => [
        'fallback' => env('POSTS_FALLBACK_LOCALE', config('app.fallback_locale', 'en')),
    ],

    'slugs' => [
        'source' => 'title',
        'separator' => '-',
        'unique' => true,
        'route-binding' => true,
        'history' => env('POSTS_SLUG_HISTORY', false),
        'lock-when-published' => false,
    ],

    'seo' => [
        'site-name' => env('POSTS_SITE_NAME'),
        'twitter-site' => env('POSTS_TWITTER_SITE'),
        'default-card' => 'summary_large_image', // 'summary' | 'summary_large_image' | 'app' | 'player'
        'default-robots' => 'index,follow',
    ],

    'json-ld' => [
        'type' => 'BlogPosting', // 'BlogPosting' | 'Article' (anything else throws)
        'author-attribute' => 'name',
        'publisher' => [
            'name' => env('POSTS_PUBLISHER_NAME'),
            'logo' => env('POSTS_PUBLISHER_LOGO'),
        ],
    ],

    'media' => [
        'featured_bucket' => 'featured',
        'gallery_bucket' => 'gallery',
        'content_bucket' => 'content',
        'disk' => env('POSTS_MEDIA_DISK'),
        'featured_fallback_url' => env('POSTS_MEDIA_FEATURED_FALLBACK'),
        'responsive_widths' => null,
        'seo_og_image' => true,
        'og_variant' => '',
        'warm_on_publish' => true,
        'inline' => [
            'enabled' => true,
            'default_variant' => '',
            'on_missing' => 'strip', // 'strip' | 'keep' (anything else throws)
        ],
    ],

    'moderation' => [
        'on_resolved' => 'archive', // 'archive' | 'draft' | null — anything else throws
        'auto_unpublish' => true,
    ],
];

Every key

KeyDefaultEnvPurpose
modelPost::class—The post model — point it at your subclass to extend it.
key_typebigintPOSTS_KEY_TYPEOutbound: your author model’s key type (bigint, uuid or ulid); types author_id. Anything else throws InvalidConfigurationException.
primary_key_typebigintPOSTS_PRIMARY_KEY_TYPEInbound: the key type of the posts, categories and tags tables and their pivots (bigint, uuid or ulid). Anything else throws InvalidConfigurationException.
tables.postsposts—Posts table.
tables.categoriespost_categories—Categories table.
tables.category_postcategory_post—Category ⇄ post pivot.
tables.tagspost_tags—Tags table.
tables.tag_postpost_tag—Tag ⇄ post pivot.
author.morph-nameauthor—Morph relation name and column prefix (author_type / author_id).
author.nullabletrue—Whether a post may have no author.
locales.fallbackapp.fallback_localePOSTS_FALLBACK_LOCALEFallback locale for translation reads and the slug chain.
slugs.sourcetitle—Attribute post slugs are generated from (categories and tags always use name).
slugs.separator-—Slug word separator.
slugs.uniquetrue—Unique per locale (-2, -3, …, trashed rows included); at migrate time also builds the per-locale unique indexes.
slugs.route-bindingtrue—The slug is the post’s route key; {post} binds along the locale chain.
slugs.historyfalsePOSTS_SLUG_HISTORYRemember retired post slugs and 301 old URLs (needs sluggable-migrations).
slugs.lock-when-publishedfalse—Freeze a published post’s slugs; a manual change throws SlugLockedException.
seo.site-namenullPOSTS_SITE_NAMEDefault og:site_name.
seo.twitter-sitenullPOSTS_TWITTER_SITEDefault twitter:site handle.
seo.default-cardsummary_large_image—Default twitter:card type: summary, summary_large_image, app or player; anything else throws.
seo.default-robotsindex,follow—Default robots directive.
json-ld.typeBlogPosting—schema.org @type: BlogPosting or Article; anything else throws.
json-ld.author-attributename—Author-model attribute used as the author name.
json-ld.publisher.namenullPOSTS_PUBLISHER_NAMEPublisher organisation name (publisher omitted when unset).
json-ld.publisher.logonullPOSTS_PUBLISHER_LOGOPublisher logo URL.
media.featured_bucketfeatured—Single-file featured-image bucket.
media.gallery_bucketgallery—Multi-file gallery bucket.
media.content_bucketcontent—Bucket owning the media referenced inline by [media:UUID].
media.disknullPOSTS_MEDIA_DISKDisk for post media (null = media-library default).
media.featured_fallback_urlnullPOSTS_MEDIA_FEATURED_FALLBACKURL featuredImageUrl() returns when no featured image is set (null = '').
media.responsive_widthsnull—Responsive width ladder of positive integers (null = media-library default, [] = no variants); any other entry throws.
media.seo_og_imagetrue—Fall back og:image and the JSON-LD image to the featured image.
media.og_variant''—Variant used for that fallback ('' = the original) — one media-library generates for the featured bucket, e.g. responsive-640; until it exists, or for an unknown name, the original’s URL is used.
media.warm_on_publishtrue—Queue variant generation for the post’s media on publish.
media.inline.enabledtrue—Expand [media:UUID] tokens in renderContent().
media.inline.default_variant''—Variant for tokens without |variant; like a token’s own, an unknown or not-yet-generated one renders the original image.
media.inline.on_missingstrip—strip or keep a token whose media the post does not own; anything else throws.
moderation.on_resolvedarchive—Action when a report against a published or scheduled post is upheld: archive, draft, or null or blank (off). A typo throws instead of skipping the auto-unpublish.
moderation.auto_unpublishtrue—Archive a published or scheduled post that crosses the global reports.threshold.

Environment

The site-specific values are env-driven, so most apps never publish the config:

POSTS_KEY_TYPE=bigint
POSTS_PRIMARY_KEY_TYPE=bigint
POSTS_FALLBACK_LOCALE=en
POSTS_SLUG_HISTORY=false
POSTS_SITE_NAME="Example Blog"
POSTS_TWITTER_SITE=@example
POSTS_PUBLISHER_NAME="Example Ltd"
POSTS_PUBLISHER_LOGO=https://example.com/logo.png
POSTS_MEDIA_DISK=public
POSTS_MEDIA_FEATURED_FALLBACK=https://example.com/images/placeholder.png

Strict values

Every bool switch is read strictly: true/1/on/yes turn it on, false/0/off/no turn it off, a blank value (POSTS_SLUG_HISTORY=) is not set so the default applies, and anything else (say POSTS_SLUG_HISTORY=disabled) throws RoundlyConsulting\PackageToolkit\Exceptions\InvalidConfigurationException instead of quietly reading as the default.

Every other setting is just as strict. A setting that is not set — absent, null, or blank like a host’s POSTS_SITE_NAME= — takes its default, and an optional one (the SEO / JSON-LD publisher values, the media disk or fallback URL) stays unset. A string setting — a table or bucket name, the morph name, the slug source or separator, a locale, an SEO or JSON-LD value, the media disk or fallback URL — must be a string when set: a non-string value throws rather than being cast to '' or replaced by the default. A seo.default-card, json-ld.type, media.inline.on_missing or moderation.on_resolved value outside its list throws, and so does a responsive width that isn’t a positive integer. The variant names — media.og_variant and media.inline.default_variant — take any string, '' meaning the original.

Checking the setup

php artisan about

php artisan about gets a Posts section: the model’s base name, both key types, whether an author is optional, the table count (DEFAULT or CUSTOMISED — a blank table name counts as the default the models use, a junk one shows INVALID), route binding, unique slugs, slug history and lock, the media disk (SET or MEDIA DEFAULT), inline media, warm-on-publish, SEO and JSON-LD presence, and the moderation switches. It never prints a configured value such as a table name, disk, publisher or site name. A broken media disk, inline-media, SEO, JSON-LD or moderation setting shows as INVALID.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.