Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Wordpress Bundle Laravel Package

goutte/wordpress-bundle

A Symfony bundle for integrating WordPress with Symfony applications. It provides configuration and services to bridge WordPress and Symfony, making it easier to combine WordPress content and features with Symfony’s structure, routing, and tooling.

View on GitHub
Deep Wiki
Context7

Getting Started

Reality check before anything else: goutte/wordpress-bundle is a Symfony2 bundle, not a Laravel package. It has no ServiceProvider, no auto-discovery, and its Composer constraints (symfony/framework-bundle ~2.3–~2.8) hard-conflict with Laravel's transitive symfony/* ^6/^7 requirements. You will not be installing this with composer require. The day-to-day value you get from it is as a reference implementation — three repository classes showing how to read wp_posts/wp_postmeta — that you port into your app in an afternoon.

Where to look first, in order:

  1. The repository classes (PostsRepository, PagesRepository, AttachmentsRepository or similarly named). This is the entire value of the package — the query shapes. Copy the approach, not the code.
  2. The DI extension + services.yml (DependencyInjection/*Extension.php, Resources/config/services.php). This tells you what the WordPress bootstrap expects: installation path, table prefix, and how the connection is configured.
  3. The kernel/event bootstrapper — the description mentions event-based initialization. Read it to understand when WordPress gets loaded, because that ordering constraint translates directly into "don't bootstrap WP in a Laravel service provider's register(); do it in a middleware or a lazy singleton."
  4. Its composer.json to confirm what it pins, so you can stop reading it.

Your actual first use case: a read-only blog/press/news page. Standard post_type = 'post', post_status = 'publish', display title/excerpt/date/featured image. That is the happy path this package was built for and the only one it credibly covers.

Minimal steps to get there in Laravel:

# 1. Create a read-only MySQL user — do this before writing any code
mysql> CREATE USER 'wp_reader'@'%' IDENTIFIED BY '...';
mysql> GRANT SELECT ON wordpress.* TO 'wp_reader'@'%';

# 2. Add a second connection
# config/database.php -> connections.wordpress (host, database, username, password)

# 3. Two models, not three, to start
class WpPost extends Model
{
    protected $connection = 'wordpress';
    protected $table      = 'wp_posts';      // prefix from config, not hard-coded
    protected $primaryKey = 'ID';
    public    $incrementing = false;
    protected $keyType    = 'int';
    public    $timestamps = false;

    public function scopePublished($q) { return $q->where('post_status', 'publish'); }
    public function scopeOfType($q, string $type) { return $q->where('post_type', $type); }
    public function meta() { return $this->hasMany(WpPostMeta::class, 'post_id', 'ID'); }
}

First milestone to aim for: a GET /blog route returning the 10 most recent published posts with title, permalink, date, and featured image — served through a ContentRepository interface so you can swap the transport later. That is roughly 150 lines and validates every risk (prefix, timezone, attachment resolution) at once.

Implementation Patterns

Wrap everything behind an interface. This is the single most important pattern here, because the package's whole thesis — "read WordPress tables directly" — is the decision you're most likely to want to reverse.

interface ContentRepository {
    public function recentPosts(int $limit, int $page = 1): LengthAwarePaginator;
    public function findPostBySlug(string $slug): ?PostDTO;
    public function pageByPath(string $path): ?PageDTO;
    public function attachmentUrl(int $id, string $size = 'full'): ?string;
}

Provide EloquentContentRepository now and keep a RestContentRepository as a documented, unimplemented sibling. Consumers (controllers, Blade views) type-hint the interface only.

Map to your own DTOs at the boundary. Don't leak WpPost into Blade. WordPress's model carries post_content with shortcodes, post_excerpt that's frequently empty, and a guid that is not a URL you should render. Convert to a PostDTO with normalized fields; this is also where you strip shortcodes and apply wpautop-equivalent formatting if your content depends on it.

Explicit allowlists for type and status — never trust the columns.

protected const PUBLIC_TYPES  = ['post', 'page', 'attachment'];
protected const PUBLIC_STATUS = ['publish'];

Revisions (post_type = 'revision'), auto-drafts (post_status = 'auto-draft'), trashed rows, and plugin-registered CPTs all live in the same table. A missing where post_status = 'publish' is the classic way to leak unpublished drafts into production. Put the allowlist in a query scope so it's impossible to forget, and add ->where('post_parent', 0) for attachments if you only want media library entries.

Eager-load meta; never lazy-load it in a loop.

$posts = WpPost::ofType('post')->published()
    ->with(['meta' => fn ($q) => $q->whereIn('meta_key', ['_thumbnail_id', '_yoast_wpseo_metadesc'])])
    ->orderByDesc('post_date_gmt')
    ->paginate(10);

wp_postmeta is the N+1 capital of WordPress. _thumbnail_id in particular is a separate row per post, so a 10-post listing without eager loading is 11 queries minimum.

Featured images require two lookups and a URL construction. _thumbnail_id postmeta → an attachment row → _wp_attached_file postmeta → base uploads URL + wp-content/uploads/ + that path. Write one attachmentUrl() method, cache it, and never reconstruct that chain inline.

public function attachmentUrl(int $id, string $size = 'full'): ?string
{
    $file = WpPostMeta::where('post_id', $id)
        ->where('meta_key', '_wp_attached_file')
        ->value('meta_value');

    return $file ? rtrim(config('wordpress.uploads_url'), '/') . '/' . ltrim($file, '/') : null;
}

Read replica + response cache, in that order. Point the wordpress connection at a replica if the WP database serves live traffic. Then put a tagged cache in front of the repository methods (posts:recent:{page}), invalidated by TTL rather than by WordPress events — you will not get reliable invalidation hooks from outside WordPress.

Config-driven, not hard-coded:

// config/wordpress.php
return [
    'connection'        => env('WP_DB_CONNECTION', 'wordpress'),
    'table_prefix'      => env('WP_TABLE_PREFIX', 'wp_'),
    'uploads_url'       => env('WP_UPLOADS_URL'),
    'allowed_post_types'=> ['post', 'page'],
    'cache_ttl'         => 300,
];

Then build table names with config('wordpress.table_prefix') . 'posts' in the model's getTable() if you need dynamic prefixes, or set $table per-environment.

Health check at boot. A wrong prefix or multisite misroute fails silently — you get zero rows, not an exception. Add an artisan command or scheduled job that asserts WpPost::ofType('post')->published()->exists() and alerts on false. This one guard catches the most common production incident for this integration.

If you need writes later, stop and re-decide. The bundle has no write path, no draft preview, no auth bridge. If a requirement appears for any of those, the correct response is a move to the WP REST API (with application passwords or a JWT plugin) or WPGraphQL — not extending the direct-DB read layer. Keep the ContentRepository interface narrow so that migration is a new implementation, not a refactor.

Gotchas and Tips

Table prefix and multisite will silently break you. wp_ is a default, not a guarantee. Multisite is worse: blogs 2..n live in wp_2_posts, wp_3_posts, etc. If the target install is multisite, you need blog-ID-aware table resolution ($wpdb->base_prefix . $blogId . '_posts'), and the bundle has no concept of this. Confirm topology before you write a single query; it changes the model layer.

post_date vs post_date_gmt — pick one and document it. post_date is site-local (whatever timezone_string is set to); post_date_gmt is UTC but is 0000-00-00 00:00:00 for scheduled/draft posts. Order listings by post_date_gmt and set APP_TIMEZONE consistently, or your "recent posts" will be subtly out of order around DST transitions. Cast to Carbon in the DTO, never hand a raw string to the view.

Serialized postmeta is a live hazard. Values are PHP-serialized strings. Never unserialize() a value you didn't write. If you must parse, use unserialize($value, ['allowed_classes' => false]) behind a try/catch, and prefer WordPress's own maybe_unserialize semantics only if you run inside a WP bootstrap (you don't). Many plugins — ACF especially — store structured data here.

Attachment URLs drift across three sources. guid looks like a URL but is a legacy identifier that may point at an old domain or a dev host. _wp_attached_file gives a relative path. A CDN/offload plugin (S3, Cloudflare) may rewrite both. Resolve through _wp_attached_file + configured base URL, and add a fallback to the CDN base if uploads_url is set. Test against a restored production snapshot, not a fresh install — that's where the domain-mismatch surprises live.

Pages order by menu_order, not date. Page hierarchies use post_parent and menu_order; a naive orderByDesc('post_date_gmt') on post_type = 'page' produces a navigation menu in seemingly random order. Build pages as a tree (post_parent = 0 roots, recursive children) if you're rendering navigation.

Drafts and previews are not reachable. There is no authenticated path to unpublished content. If editorial asks for "preview this draft on staging," the answer is a token-gated REST call, not a query tweak. Say this at estimation time, because it's a common and reasonable ask.

post_content is not HTML you can render. It carries shortcodes ([gallery], [caption]), block comments (<!-- wp:paragraph -->), and un-processed newlines. Passing it straight into Blade gives you either raw block markup or literal shortcode text. Either run it through a WP bootstrap, or accept a degraded render and strip shortcodes + apply a paragraph formatter. Decide this explicitly; it is a visible product decision.

Watch the query shapes. wp_postmeta lookups on meta_key alone are frequently unindexed — the default index is (post_id, meta_key), so WHERE meta_key = '_thumbnail_id' across all posts is a full scan. Run EXPLAIN on every new query shape before it ships, and never let a Laravel batch job scan wp_posts while the WordPress front end is serving traffic from the same database.

Instrument the failure modes. Log a warning (not an exception) when a repository method returns zero rows for a query you know should match — that's usually prefix, multisite, or a status allowlist problem. Also log slow queries on the wordpress connection separately so WP-induced latency is attributable.

Extension points, ranked. (1) ContentRepository — swap transport to REST. (2) Model getTable() / $connection — swap install or replica. (3) DTO mappers — swap rendering rules. Do not extend the bundle itself, and do not fork it; there's nothing upstream to stay compatible with, and 15 stars / 0 dependents means you'd own the fork outright.

Debugging checklist, in order: correct connection? (DB::connection('wordpress')->select('select 1')) → correct prefix? (SHOW TABLES LIKE '%posts') → rows exist at all? (SELECT COUNT(*) FROM wp_posts WHERE post_status='publish') → does your scope match? (toSql() + bindings) → is it cached with a stale key? That sequence resolves 90% of "the blog is empty" reports in under five minutes.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console