* Give the foreign key a backing index before dropping the unique social_accounts.workspace_id carries a foreign key, and the composite unique index is the only one covering it, as its leftmost prefix. MySQL refuses to drop the sole index backing a foreign key (SQLSTATE[HY000] 1553), so both rehearsal suites failed in beforeEach and never ran a single assertion on MySQL. Add a plain index on workspace_id first; PostgreSQL has no such requirement and simply carries it. This unmasks one assertion underneath that had never executed: the automation graph comparison at DuplicateIdentityMigrationTest.php:419 depended on JSON object key order, which MySQL normalises on storage. (cherry picked from commit 98a494bd2205e873321a18232f63b358ae259fdf) * Compare JSON payloads without depending on key order MySQL normalises JSON object keys (length, then lexicographic) on storage, so an identity comparison against a literal asserts how the driver chose to lay the object out rather than what it contains. PostgreSQL preserves insertion order, which is why these passed there. toEqual compares associative arrays recursively without regard to key order. Applied to every assertion in this class, including the few that pass today only because their keys already happen to match MySQL's ordering. (cherry picked from commit 3124023c548d6c2b8b52126afc6fc5f38d461ea6) * Match logged SQL without depending on identifier quoting Four DB::listen predicates matched 'select * from "post_platforms"'. PostgreSQL quotes identifiers with double quotes and MySQL with backticks, so on MySQL the predicates never matched, the simulated mid-run pause never fired, and the race these tests exist to cover went unexercised while the tests still reported failures elsewhere. Compare against the unquoted form via a small helper. (cherry picked from commit 67a81df5de155e80227df748b34cd8b3cfd744f9) * Cast raw boolean reads in tests so they pass on MySQL Three assertions read oauth_refresh_tokens.revoked through the query builder rather than Eloquent, so no cast applies and the driver's native representation leaks into the test: a real boolean on PostgreSQL, 1 on MySQL. Cast explicitly at the call site. (cherry picked from commit 2911c5c48cf65d24a34a41e667335c40005839a7) * Use a scheduling date inside MySQL's TIMESTAMP range MySQL TIMESTAMP columns end at 2038-01-19, so the 2099 sentinel these tests used is rejected outright with SQLSTATE[22007]. 2037-12-31 still reads as a far-future schedule and works on both engines. (cherry picked from commit bde33eb239cdbd3a5567d4c21e1d85302913cdd7) * Remove the duplicate-identity migration scenario test The suite rebuilt a pre-migration schema by dropping the unique index in beforeEach and re-running the migration by hand, exercising a database state the application never runs in. * Fix the MySQL rollback path and run CI on both engines The migration's down() dropped a unique whose leftmost prefix is an FK column, which MySQL refuses when nothing else backs the constraint (SQLSTATE 1553). It now creates a standalone index first, so migrate:rollback works on MySQL and stays a no-op change for PostgreSQL. up() is untouched: every database already migrated keeps its schema. The rehearsal test calls that down() instead of hand-rolling the drop, so it exercises the real rollback rather than an imitation of it. Matches logged SQL through the connection's query grammar rather than stripping quote characters, and adds a MySQL leg to the backend CI job. * Use a readiness check both database images can run mysql:8.4 installs mysql-community-server-minimal, which ships neither mysqladmin nor the mysql client, so a mysqladmin health command never succeeds and the service never reports healthy. Both images run their init phase without networking, so an open port is the point either engine starts accepting connections - one check covers both, and the per-engine matrix key goes away. * Use each engine's own readiness tool pg_isready and mysqladmin ping are what the respective images ship for this, and the mysql image's entrypoint invokes mysqladmin itself, so it is present. Keeps 20 retries, which MySQL needs to finish initialising. * State the two-engine ceiling as a rule, not a test detail The 2038 TIMESTAMP limit binds anything written to the column, not just the sentinel dates in fixtures, and the same reasoning generalises: what the app supports is the intersection of both engines. * Let the release image connect to MySQL The published image installed only pdo_pgsql, so DB_CONNECTION=mysql failed with "could not find driver" before any query ran - the app supports MySQL but the image people actually deploy could not reach it. mysql-client mirrors the postgresql-client already present, for artisan db and dumps. * Keep "backend" a single required status check Matrixing the job split its check in two, so the "backend" context the branch protection requires was never reported and every PR sat waiting on it. The matrix is now "tests" and a small "backend" job gates on it, which keeps the required check stable however many engines the matrix grows to - and leaves the open PRs mergeable without a rebase. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
33 KiB
Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
Foundational Context
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
Before relying on a package's API, confirm its installed version:
- PHP packages: run
composer show --directto list direct dependencies with versions, orcomposer show <vendor/package>for a single package. - JS packages: check
package.jsonfor the installed versions.
Skills Activation
This project has domain-specific skills available in **/skills/**. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example,
isRegisteredForDiscounts, notdiscount(). - Check for existing components to reuse before writing a new one.
Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run
npm run build,npm run dev, orcomposer run dev. Ask them.
Documentation Files
- You must only create documentation files if explicitly requested by the user.
Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
Laravel Boost
Tools
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use
database-queryto run read-only queries against the database instead of writing raw SQL in tinker. - Use
database-schemato inspect table structure before writing migrations or models. - Use
get-absolute-urlto resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. - Use
browser-logsto read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
Searching Documentation (IMPORTANT)
- Always use
search-docsbefore making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. - Pass a
packagesarray to scope results when you know which packages are relevant. - Use multiple broad, topic-based queries:
['rate limiting', 'routing rate limiting', 'routing']. Expect the most relevant results first. - Do not add package names to queries because package info is already shared. Use
test resource table, notfilament 4 test resource table.
Search Syntax
- Use words for auto-stemmed AND logic:
rate limitmatches both "rate" AND "limit". - Use
"quoted phrases"for exact position matching:"infinite scroll"requires adjacent words in order. - Combine words and phrases for mixed queries:
middleware "rate limit". - Use multiple queries for OR logic:
queries=["authentication", "middleware"].
Project Rules
- This project keeps committed, area-grouped rules in
.ai/rules(settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under.ai/rules/boost— this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and rungrep -rin 'keyword' .ai/rulesto catch what a path match alone misses. Do not write code until you have read and are following every matching rule. - Record durable rules with
record-ruleso the next agent or teammate inherits them instead of working them out again. Pass aglob(e.g.app/Http/Controllers/**), a shorttitle, and a few-linenote. Always userecord-rule, never your native memory or notes tool — native memory is personal and session-scoped; only.ai/rulesis shared with the team and persists in the repo.
Artisan
- Run Artisan commands directly via the command line (e.g.,
php artisan route:list). Usephp artisan listto discover available commands andphp artisan [command] --helpto check parameters. - Inspect routes with
php artisan route:list. Filter with:--method=GET,--name=users,--path=api,--except-vendor,--only-vendor. - Read configuration values using dot notation:
php artisan config:show app.name,php artisan config:show database.default. Or read config files directly from theconfig/directory.
Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion:
php artisan tinker --execute 'Your::code();'- Double quotes for PHP strings inside:
php artisan tinker --execute 'User::where("active", true)->count();'
- Double quotes for PHP strings inside:
=== php rules ===
PHP
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion:
public function __construct(public GitHub $github) { }. Do not leave empty zero-parameter__construct()methods unless the constructor is private. - Use explicit return type declarations and type hints for all method parameters:
function isAccessible(User $user, ?string $path = null): bool - Use TitleCase for Enum keys:
FavoritePerson,BestLake,Monthly. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
Deployment
- Laravel can be deployed using Laravel Cloud, which is the fastest way to deploy and scale production Laravel applications.
=== herd rules ===
Laravel Herd
- The application is served by Laravel Herd at
https?://[kebab-case-project-dir].test. Use theget-absolute-urltool to generate valid URLs. Never run commands to serve the site. It is always available. - Use the
herdCLI to manage services, PHP versions, and sites (e.g.herd sites,herd services:start <service>,herd php:list). Runherd listto discover all available commands.
=== tests rules ===
Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use
php artisan test --compactwith a specific filename or filter.
=== inertia-laravel/core rules ===
Inertia
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
- Components live in
resources/js/pages(unless specified invite.config.js). UseInertia::render()for server-side routing instead of Blade views. - ALWAYS use
search-docstool for version-specific Inertia documentation and updated code examples. - IMPORTANT: Activate
inertia-vue-developmentwhen working with Inertia Vue client-side patterns.
Inertia v3
- Use all Inertia features from v1, v2, and v3. Check the documentation before making changes to ensure the correct approach.
- New v3 features: standalone HTTP requests (
useHttphook), optimistic updates with automatic rollback, layout props (useLayoutPropshook), instant visits, simplified SSR via@inertiajs/viteplugin, custom exception handling for error pages. - Carried over from v2: deferred props, infinite scroll, merging props, polling, prefetching, once props, flash data.
- When using deferred props, add an empty state with a pulsing or animated skeleton.
- Axios has been removed. Use the built-in XHR client with interceptors, or install Axios separately if needed.
Inertia::lazy()/LazyProphas been removed. UseInertia::optional()instead.- Prop types (
Inertia::optional(),Inertia::defer(),Inertia::merge()) work inside nested arrays with dot-notation paths. - SSR works automatically in Vite dev mode with
@inertiajs/vite- no separate Node.js server needed during development. - Event renames:
invalidis nowhttpException,exceptionis nownetworkError. router.cancel()replaced byrouter.cancelAll().- The
futureconfiguration namespace has been removed - all v2 future options are now always enabled.
=== laravel/core rules ===
Do Things the Laravel Way
- Use
php artisan make:commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands usingphp artisan listand check their parameters withphp artisan [command] --help. - If you're creating a generic PHP class, use
php artisan make:class. - Pass
--no-interactionto all Artisan commands to ensure they work without user input. You should also pass the correct--optionsto ensure correct behavior.
Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using
php artisan make:model --helpto check the available options.
APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
URL Generation
- When generating links to other pages, prefer named routes and the
route()function.
Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as
$this->faker->word()orfake()->randomDigit(). Follow existing conventions whether to use$this->fakerorfake(). - When creating tests, make use of
php artisan make:test [options] {name}to create a feature test, and pass--unitto create a unit test. Most tests should be feature tests.
Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run
npm run buildor ask the user to runnpm run devorcomposer run dev.
=== wayfinder/core rules ===
Laravel Wayfinder
Use Wayfinder to generate TypeScript functions for Laravel routes. Import from @/actions/ (controllers) or @/routes/ (named routes).
=== pint/core rules ===
Laravel Pint Code Formatter
- If you have modified any PHP files, you must run
vendor/bin/pint --dirty --format agentbefore finalizing changes to ensure your code matches the project's expected style. - Do not run
vendor/bin/pint --test --format agent, simply runvendor/bin/pint --format agentto fix any formatting issues.
=== pest/core rules ===
Pest
- This project uses Pest for testing. Create tests:
php artisan make:test --pest {name}. - The
{name}argument should not include the test suite directory. Usephp artisan make:test --pest SomeFeatureTestinstead ofphp artisan make:test --pest Feature/SomeFeatureTest. - Run tests:
php artisan test --compactor filter:php artisan test --compact --filter=testName. - Do NOT delete tests without approval.
=== inertia-vue/core rules ===
Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate
inertia-vue-developmentwhen working with Inertia Vue client-side patterns.
Project-Specific Rules
Frontend (Vue/TypeScript)
- Always use arrow functions in Vue components and TypeScript files. Never use
functiondeclarations.
Inertia SSR
- This project does not run Inertia SSR.
config/inertia.phpdefaultsssr.enabledtofalseand nothing in the repo setsINERTIA_SSR_ENABLED. - Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing
Http::fake()stubs. - The build wiring is still shipped (
resources/js/ssr.ts,vite.config.ts,npm run build:ssrindocker/Dockerfile). Turning SSR on means building that bundle and runninginertia:start-ssralongside the app, not just flipping the env.
Dialogs
- In
<DialogFooter>, put the primary action button first in the markup, then secondary/cancel (e.g. Save → Cancel).DialogFooterusesflex-colon mobile (primary on top, cancel at the bottom) andsm:flex-row sm:justify-starton desktop, so the first child is the leftmost action on larger screens. - Match sibling dialogs in the same feature area before inventing a new footer layout.
AI agents (app/Ai/Agents)
- Never embed prompts in PHP (
<<<PROMPT, heredocs, or long string literals ininstructions()). - Put system/instruction text in Blade under
resources/views/prompts/(e.g.prompts.post_content.generator,prompts.post_image.regenerator). - In
instructions(), returnview('prompts....', [...])->render()and pass only the variables the Blade file needs — same pattern asPostContentStreamer,PostContentReviewer, andBrandAnalyzer.
System AI (always allowed, never metered)
- The brand analyzer / workspace autofill (
App\Services\Brand\BrandAnalyzerRunner,App\Actions\Ai\AutofillBrand,WorkspaceController::autofillBrand) is a system feature, not the user's AI usage. It runs during workspace creation, before the user has AI access. - It MUST always be allowed: NEVER gate it behind the
useAipolicy, an active subscription, or a credit check. - It MUST NOT deduct anything: NEVER call
RecordAiUsage(or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's. - Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.
Stripe Checkout (env knobs)
Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through App\Support\Billing\ConfigureSubscriptionCheckout (called from StartSubscriptionCheckout).
| Env | Config | Default | Effect |
|---|---|---|---|
REQUIRE_CARD_FOR_TRIAL |
trypost.billing.require_card_for_trial |
true |
true: app access only after Stripe Checkout (no generic signup trial). false: generic accounts.trial_ends_at trial without a card |
CASHIER_TRIAL_DAYS |
cashier.trial_days |
8 |
Card-required Checkout: trialDays(N) for first-time subscribers when no first-month coupon is applied (0 = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial |
STRIPE_FIRST_MONTH_COUPON_ID |
cashier.first_month_coupon_id |
empty | Optional. When set for a qualifying first-time single-workspace checkout, applies withCoupon and skips trial. Empty = trial mode |
CASHIER_ALLOW_PROMOTION_CODES |
cashier.allow_promotion_codes |
false |
When true and no coupon is applied, show the Checkout promo-code field |
Standing constraints:
- Stripe rejects
discounts(coupon) andallow_promotion_codeson the same session — if both would apply,ConfigureSubscriptionCheckoutmust throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does not qualify for the coupon (no throw). - A set first-month coupon wins over trial (
trialDaysis skipped for that checkout). - Empty coupon + card required + first-time must use
trialDays— do not reintroduce a required-coupon throw. - Coupon qualification stays: card required, exactly one workspace, no prior real subscription (
incomplete/incomplete_expiredstill qualify). - Prefer documenting durable billing decisions here (and in
AGENTS.md) — do not create a.ai/rules folder for this project.
Multiple social accounts per network
One connected identity per social network per workspace is the Cloud default. This is not tied to SELF_HOSTED — Cloud cannot flip that flag, but it can flip this one.
| Env | Config | Default | Effect |
|---|---|---|---|
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS |
trypost.allow_multiple_social_accounts |
false (falls back to SELF_HOSTED when unset) |
true: a workspace may connect more than one account of the same network (two LinkedIns, two Instagrams, …). false: one per network (LinkedIn profile + page count as one; Instagram standalone + Instagram-via-Facebook count as one). Reconnecting the same platform + platform_user_id still updates the existing row. Shared to Inertia as allowMultipleSocialAccounts. |
Self-hosted compose / .env.example set this true. When the env is unset, the config falls back to SELF_HOSTED so existing self-hosted installs keep multiple accounts. Do not use selfHosted for the occupancy check (observer, Telegram connect, NetworkConnectGrid).
Icons (@tabler/icons-vue)
- This project uses
@tabler/icons-vuefor all icons. NEVER uselucide-vue-next. - All Tabler icons are prefixed with
Icon, e.g.IconCheck,IconChevronRight,IconMail. - Import icons from
@tabler/icons-vue:import { IconCheck, IconX } from '@tabler/icons-vue'. - Browse available icons at https://tabler.io/icons
Dates
- For date manipulation, always use
@/dayjs(pre-configured dayjs instance with utc, timezone, relativeTime plugins). - For formatting dates for display (formatDate, formatDateTime, formatTime, diffForHumans), always use
@/datewhich centralizes all formatting logic with proper timezone handling. - Never use raw
new Date()for date calculations — use dayjs.
Routing (Wayfinder)
- This project uses Laravel Wayfinder for type-safe frontend routing.
- ALWAYS use Wayfinder-generated route helpers in Vue pages (e.g.
register(),login(),dashboard()). NEVER hardcode URL strings likehref="/register". - After creating or modifying PHP routes/controllers, run
php artisan wayfinder:generateto regenerate the TypeScript route helpers. - Import routes from
@/routes/...(e.g.import { store } from '@/routes/login').
Pagination
- Always use normal pagination (
->paginate()). NEVER use cursor pagination (->cursorPaginate()). - All paginated lists must use Inertia's scroll pagination (
Inertia::scroll()on the backend with<InfiniteScroll>on the frontend). NEVER use traditional page-based pagination with page links/buttons. - The page size ALWAYS comes from
config('app.pagination.default')— never a magic number, and never aperPage/per_pagevalue supplied by the request or frontend. Action/service list methods must NOT accept a$perPageparameter; call->paginate((int) config('app.pagination.default'))directly.- The only exception is the public REST API (
app/Http/Controllers/Api), which uses its own fixed, documented page size (15) as a stable API contract.
- The only exception is the public REST API (
Form Validation
- NEVER use HTML5 validation attributes (
required,minlength,pattern, etc.) on form inputs. Always rely solely on backend validation.
Backend Validation
- Validation rules always live in a dedicated
Illuminate\Foundation\Http\FormRequestsubclass underapp/Http/Requests/App/<Group>/. Controller actions must type-hint the FormRequest as the parameter — NEVER call$request->validate([...])inline in the controller. - Naming:
<Verb><Resource>Request.php(e.g.StorePostRequest,UpdatePostRequest,LinkPreviewRequest).
Database engines (PostgreSQL + MySQL)
TryPost runs on both PostgreSQL and MySQL. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each.
- What the app supports is the intersection of the two engines, never the superset of one. When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have.
- Never use an engine-specific operator or function. Search uses
whereLike()(Laravel handles the case-insensitive form per driver), neverilikeor a rawLOWER(...)comparison. - Traps that only surface on MySQL:
- JSON object key order is not preserved. MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with
toEqual(recursive, order-independent), nevertoBe/assertSame. Array element order is preserved on both. $table->timestamp()tops out at 2038-01-19. PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to atimestamp()column may go past it — scheduled posts, expiry sentinels and test fixtures alike.2037-12-31reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.- Raw query-builder reads carry no Eloquent cast, so the driver's native shape leaks through:
DB::table(...)->value('some_bool')istrueon PostgreSQL and1on MySQL. Read through the model, or useassertDatabaseHas. - Identifier quoting differs — PostgreSQL emits
"post_platforms", MySQL emits backticks. Never match logged SQL (DB::listen) against a quoted identifier. - MySQL refuses to drop the only index backing a foreign key (SQLSTATE
1553). A migrationdown()that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first. - DDL implicitly commits, which defeats
RefreshDatabase's rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.
- JSON object key order is not preserved. MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with
Per-Platform Post Meta (PostPlatform.meta)
- All
platforms.*.metavalidation (the parent array rule AND every per-platform sub-key:aspect_ratio, TikTokprivacy_level/flags, Pinterestboard_id, Discordchannel_id/mentions/embeds, etc.) lives in ONE place:App\Support\PostPlatformMetaRules.- Every post create/update entry point — web (
App\Http\Requests\App\Post\UpdatePostRequest), public API (App\Http\Requests\Api\Post\{Store,Update}PostRequest), and MCP (App\Mcp\Tools\Post\{Create,Update}PostTool) — spreads...PostPlatformMetaRules::rules(). NEVER add a per-platform meta rule inline to a single request/tool. - Why:
FormRequest::validated()(and MCP$request->validate()) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized.
- Every post create/update entry point — web (
- Required-on-publish (meta a platform needs to publish, e.g. Discord
channel_id) also lives there:addRequiredOnPublishErrors()for request-driven flows (web/API updatewithValidator),assertStoredPostPublishable()for flows that publish stored state without resubmitting platforms (MCPPublishPostTool). Add new required-meta rules torequiredMetaViolation(), not inline. - When adding a new platform's meta field, add it (and any publish requirement) to
PostPlatformMetaRulesONLY, and cover it intests/Feature/Api/PostApiPlatformMetaTest.php+tests/Feature/Mcp/PostPlatformMetaToolTest.php.
Media Types (image / video / document)
- A media item is one of exactly three types: image, video, document (PDF). There is no standalone "audio" media type (audio exists only as a video voiceover input).
- Media-type detection lives in ONE place per side — NEVER hand-write
type === 'image',mime_type === 'application/pdf',mime.startsWith('video/'), or extension checks inline.- Backend:
App\Enums\Media\Type—classify(),fromMime(),fromExtension(),isGif(), plus theallowedMimeTypes()/extensions()allow-lists. Use these, never a raw MIME/extension comparison. - Frontend:
resources/js/lib/mediaType.ts— the mirror of the backend enum: theMediaTypeunion,classify(),fromMimeType()(for a browserFile.type),fromExtension(),isImage()/isVideo()/isDocument()/isGif().@/composables/useMediare-exportsisImageMedia/isVideoMedia/isDocumentMediaaliases for legacy call sites. - Detection trusts the explicit
typefirst, then the MIME, then the filename extension — so an item with only a MIME (e.g. AI/Unsplash/Giphy media without atype) still classifies correctly. A bareitem.type === 'image'(with av-elsevideo) silently mis-renders those.
- Backend:
- The
typefield on every media-ish interface is theMediaTypeunion, neverstring—MediaItem, and any sibling picked/asset/saved shape (PickedMedia,AssetMedia,SavedMedia, etc.). - The upload
acceptattribute for "everything we allow" comes fromacceptAttribute()(frontend) /Media\Type::allowedMimeTypes()(backend) — never a hardcoded MIME list. Per-capabilityacceptbuilders driven by content-type rules (e.g.image/*,video/*) are fine; those aren't detection.
Pest / Feature Tests
- ALWAYS use named routes via the
route()helper in feature tests. NEVER hardcode URL strings like'/posts/ai/create'.- Example:
$this->postJson(route('app.posts.store'))instead of$this->postJson('/posts'). - With params:
route('app.posts.ai.create.finalize', $creationId).
- Example:
Browser Tests (Pest + Playwright)
Browser tests live in tests/Browser and run on pestphp/pest-plugin-browser driving Playwright. Laravel Dusk is not installed — there is no DuskTestCase, no $browser object, and no browse(). Do not add dusk="..." attributes; they select nothing.
- ALWAYS use named routes via
route(). NEVER hardcode URLs like'https://trypost.test/login'.- Example:
visit(route('login')).
- Example:
- ALWAYS target elements by
data-testid. NEVER use CSS classes (.text-red-600), tag names, or text strings.@my-elementresolves to[data-testid="my-element"], so adddata-testid="my-element"in the Vue component and use$page->click('@my-element').- Bind it for repeated elements:
:data-testid="connect-${platform.value}".
- Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the
waitFor*TestId()helper at the top oftests/Browser/WelcomeConnectTest.phpand copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files). BrowserTestCasesets$fakesVite = falseon purpose: these tests load real built assets, so faking Vite blanks the app.- End page assertions with
->assertNoJavaScriptErrors(). - CI runs them un-parallelised (
php artisan test tests/Browser --compact) againstnpm run buildoutput, so keep them independent of a running dev server.
Array Data Access
- In Action classes and similar service classes, ALWAYS use Laravel's
data_get()helper instead of direct array access.- Example:
data_get($data, 'name')instead of$data['name']. - Use the third parameter for fallback values:
data_get($data, 'username', $sender->username)instead of$data['username'] ?? $sender->username.
- Example:
Eloquent Models & Morph Map
- EVERY Eloquent model in
app/ModelsMUST be registered inRelation::enforceMorphMap([...])insideAppServiceProvider::configureMorphMap(), keyed by a camelCase alias (e.g.'postPlatform' => PostPlatform::class). - When you add a new model, add it to the morph map in the same change.
tests/Unit/MorphMapTest.phpfails if any model is missing. - The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows.
Imports
- NEVER use inline class references (e.g.,
\DB::listen,\Str::uuid()). ALWAYS import classes at the top of the file with ausestatement.- PHP:
use Illuminate\Support\Facades\DB;thenDB::listen(...) - TypeScript/Vue:
import { ref } from 'vue'thenref(...)
- PHP:
API Response Status Codes
- When returning JSON responses with explicit status codes, always use
Symfony\Component\HttpFoundation\Responseconstants instead of magic numbers.- Example:
Response::HTTP_CREATEDinstead of201,Response::HTTP_NO_CONTENTinstead of204.
- Example:
String Interpolation
- When injecting variables into strings, prefer double-quoted interpolation with curly braces over concatenation with
..- PHP:
"workspace.{$workspace->id}"instead of'workspace.'.$workspace->id. - Use curly braces
{}even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity. - Single quotes are still preferred when the string has no interpolation.
- PHP:
External Service URLs
- NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g.
https://api.x.com/2,https://www.linkedin.com/oauth/v2/accessToken,https://bsky.social). They live inconfig/trypost.phpunderplatforms.<name>with a matchingenv(...)default, so self-hosted users can override them and we have a single source of truth.- Production code:
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', never the literal URL. - Tests: use the same
config(...)value inHttp::fake([...])—Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...]). Tests with hardcoded URLs drift silently when the config changes. - Path/route segments after the host (e.g.
/oauth/v2/accessToken,/xrpc/com.atproto.server.refreshSession) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.
- Production code:
Social Platform API Documentation (official sources)
Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory. APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with:
- Facebook / Instagram / Threads (Meta): all three share the Graph API error format (
error.code,error.type).- General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/
- Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 80000–80014 — e.g.
80001Pages API,80002Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/ - Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/
- Instagram media reference (incl.
DELETE): https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/ - Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists. Delete posts (needs the separate
threads_deletepermission, 100 deletes/day/account): https://developers.facebook.com/docs/threads/posts/delete-posts/ - Our
App\Services\Social\Meta\GraphError(used byConnectionVerifier's verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification. Facebook/InstagramFacebookSocialAccounts use a Facebook Page access token (BUC-limited);Instagram(direct login) andThreadsuse a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform.
- X (Twitter): API v2 — https://docs.x.com/x-api ; Post management (create/delete) — https://docs.x.com/x-api/posts/manage-tweets/introduction
- LinkedIn: Posts API (create/update/delete, member + organization) — https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api (replaces the deprecated
ugcPostsAPI) - Mastodon: Statuses API — https://docs.joinmastodon.org/methods/statuses/
- Pinterest: API v5 reference — https://developers.pinterest.com/docs/api/v5/
- YouTube: Data API v3 — https://developers.google.com/youtube/v3/docs
- TikTok: Content Posting API — https://developers.tiktok.com/doc/content-posting-api-reference-direct-post — no delete/unpublish endpoint exists; a published post can only be removed manually inside the TikTok app
- Bluesky / AT Protocol: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app
- Discord: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook
- Telegram: Bot API — https://core.telegram.org/bots/api
TryPost.it Documentation
- All our documentation to final user it's under https://docs.trypost.it
Git
- NEVER add
Co-Authored-Bylines to commit messages. - NEVER commit, push, or open PRs unless explicitly asked by the user.
- Always create a new branch for feature work before making changes.