Extends Sites with cms_type + WP Application Password credentials and adds a posts:publish Artisan command to push a Post to a WordPress site's REST API, mirroring the reddit-post sidecar's role for a native, non-browser-automation CMS target.
7.1 KiB
WordPress Publishing — Design
Purpose
Extends the existing Sites & Posts data model so a Site can be marked as a WordPress-powered CMS with its own REST API credentials, and adds an Artisan command that publishes an existing draft/scheduled Post to that site via the WordPress REST API. This is the WordPress equivalent of the reddit-post sidecar, but implemented natively in Laravel rather than as an external Playwright process, since WordPress's REST API is a direct HTTP call and the Post/Site data already lives in this app's database.
Scope
- Distinguish a Site's CMS by a
cms_typefield:wordpress,custom, orother. - Store WordPress Application Password credentials (
wp_username+wp_app_password) on a Site. - Add
php artisan posts:publish {post}— loads aPostby ID, publishes it to its Site's WordPress REST API, and updates the Post'sstatusandpublished_url. - Update the Sites Livewire UI to manage
cms_typeand the WordPress credential fields. - Expose the new Site fields via the existing read-only Sites API, and the new Post field via the existing Posts API.
Out of scope:
- Any CMS integration beyond WordPress —
customandotherare labels only, with no publish path. - Updating/editing an already-published WP post, deleting a WP post, or any WP endpoint beyond creating a post.
- Media/image uploads, categories, tags, excerpts, or any WP post field beyond title/content/status.
- Scheduled publishing via WP's own
futurepost status — this command always publishes immediately (status: publish) when invoked; scheduling is this app's ownPost.scheduled_at+ AI-driven polling, unchanged from the existing design. - Authentication/authorization on the command, UI, or API (consistent with the rest of the app — local network only).
- Any plugin-based WP auth scheme (JWT, API-key plugins) — Application Passwords (built into WordPress core 5.6+) only.
Data Model
sites table — new columns
| Column | Type | Constraints |
|---|---|---|
| cms_type | string (enum) | required, default custom — one of: wordpress, custom, other |
| wp_username | string | nullable |
| wp_app_password | string | nullable, plain text (no hashing/encryption — same precedent as social_accounts.password) |
wp_username/wp_app_password are only meaningful when cms_type = wordpress; no DB-level constraint enforces this pairing (validated at the UI/command level instead).
posts table — new column
| Column | Type | Constraints |
|---|---|---|
| published_url | string | nullable — set on successful publish; generic across future publish targets, not WordPress-specific |
Command: php artisan posts:publish {post}
Arguments: one required argument, the Post id.
Flow:
- Load the
Postby id (404/"post not found" error and exit 1 if missing). Eager-load itssiterelation. - Validate, in order, failing fast with a descriptive message and exit code 1 on the first failure:
post.type === 'blog'— else"Post {id} has type '{type}', expected 'blog' to publish to a CMS."post.site.cms_type === 'wordpress'— else"Site '{name}' is not configured as WordPress (cms_type={cms_type})."post.site.wp_usernameandpost.site.wp_app_passwordare both present — else"Site '{name}' is missing WordPress credentials."- These three pre-flight checks happen before any DB write to the Post — a mismatched post/site pairing is a usage error, not a publish failure, so the Post's
statusis left untouched in these cases.
- Send
POST {site.url}/wp-json/wp/v2/postsvia Guzzle, HTTP Basic Auth (wp_username/wp_app_password), JSON body:{ "title": post.title, "content": post.body, "status": "publish" } - On success (2xx response with a
linkfield in the JSON body):- Update the Post:
status = 'published',published_url = response.link. - Print
{"success": true, "url": "<published_url>"}to stdout, exit 0.
- Update the Post:
- On failure (network/connection error, non-2xx response, or malformed response missing
link):- Update the Post:
status = 'failed'. - Print
{"success": false, "error": "<description>"}to stdout, exit 1. - Error description includes the WP error message when the response body has one (WP REST errors return
{code, message, data}), otherwise a generic description of the failure (e.g. "could not connect to site", "unexpected response from WordPress").
- Update the Post:
Title handling: Post.title is nullable in the existing schema, but WordPress requires a title. If post.title is null/empty at step 3, this is a pre-flight validation failure (same category as step 2): "Post {id} has no title, required to publish to WordPress." — exit 1, Post status untouched.
UI (Livewire)
Sites create/edit form gains:
- CMS Type — dropdown: WordPress / Custom / Other (default Custom).
- When WordPress is selected, two additional fields appear:
- WP Username — text input.
- WP Application Password — masked input with reveal toggle (same convention as the Social Accounts password field). Not required at the DB level, but the form validates both are present together when
cms_type = wordpressis submitted (client-side/Livewire validation — DB stays nullable to avoid migration headaches if a site is later switched away from WordPress).
Sites table view gains a CMS Type column. No changes to the Posts UI (the new published_url field is visible as a link in the Posts table's existing "actions" area or a new column — small addition, decided at plan time).
API
No new endpoints.
GET /api/sites/GET /api/sites/{id}—SiteResourcenow also returnscms_type,wp_username,wp_app_password(plain text), consistent with the existing precedent that Social Accounts' API already returns plain-text passwords, since the AI agent needs credentials to act directly.GET /api/posts/GET /api/posts/{id}— response shape gainspublished_url.
Testing / Edge Cases
- Feature test:
posts:publishon a post withtype != 'blog'fails with the expected message, exit 1, Post status unchanged. - Feature test:
posts:publishon a post whose site hascms_type != wordpressfails accordingly, Post status unchanged. - Feature test:
posts:publishon a WordPress site missing credentials fails accordingly. - Feature test:
posts:publishon a post with no title fails accordingly. - Feature test: successful publish (mocked HTTP via Guzzle's mock handler / Laravel's
Http::fakeequivalent) — asserts Post updated tostatus=publishedwith correctpublished_url, and the printed JSON/exit code are correct. - Feature test: WP API returns an error response (e.g. 401 invalid credentials, 500) — asserts Post updated to
status=failed, correct error message surfaced, exit 1. - Feature test: WP API unreachable (connection exception) — asserts graceful failure,
status=failed, exit 1. - Livewire test: Sites form shows/hides WP fields based on
cms_typeselection, and validates the paired-fields rule. - Migration test/manual check: existing sites default to
cms_type=customwith null WP fields (no backfill needed beyond the column defaults).