Production crashed on every Inertia request after the PostHog branch
landed:
TypeError: App\Models\Account::cachedPostCount(): Return value must
be of type int, string returned at app/Models/Traits/HasUsage.php:80
Root cause: Laravel's RedisStore optimises is_numeric values by storing
them raw (not serialised) so they remain INCR/DECR-able atomically.
The side effect is that an int written via Cache::put comes back as a
string on read. The strict ': int' return type on cachedPostCount then
threw a TypeError.
Local dev and CI used the file/array/database drivers respectively,
which serialise everything blindly and preserve the int type, so the
bug never surfaced before deploy.
Fixes:
- Cast the Cache::remember result to (int) — defensive, survives any
driver-specific behaviour. Documented inline so the cast is not
later removed as redundant.
- Change config/cache.php default from 'database' to 'redis' so local
dev matches prod by default and similar driver-specific bugs surface
before merge instead of after deploy.
- Regression test that seeds the cache with a literal string (mimics
the production Redis read) and asserts cachedPostCount still returns
an int.
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
The TrackBilling job was being enqueued on every Stripe webhook event
even with POSTHOG_ENABLED=false. handle() short-circuited via the
isEnabled() check, so nothing reached PostHog, but the job still
consumed queue worker cycles (20-300ms each) on accounts where Stripe
fires its frequent customer.subscription.updated events.
Adds the missing isEnabled() check at the dispatch site in
trackPlanChange, plus the two test cases that would have caught this
the first time around (TrackBilling not dispatched when enabled=false,
and not dispatched when api_key is missing). Updates the listener test
beforeEach to opt the suite into the enabled path so the existing
assertDispatched() assertions continue to fire.
Two follow-ups from the final pre-merge review:
1. SyncUser and TrackBilling shipped \$account->plan?->slug, which is a
PlanSlug backed enum. json_encode renders it correctly on the wire,
but the queue payload carries an enum instance — anyone introspecting
the job (Bus::fake, future workers) and string-comparing the slug
would fail silently. Cast to ->slug->value at the call sites.
2. PostHogServiceTest only covered the api_key=null negative case. The
primary scenario the gate exists to defend (self-hosted with a stale
POSTHOG_API_KEY but POSTHOG_ENABLED=false) was not asserted. Added
four tests covering capture/identify/groupIdentify with enabled=false
and an api key present, plus a direct truth-table check on
isEnabled() requiring both flags.
Three fixes from a fresh code review:
1. SyncUser identify used 'email' / 'name' instead of the PostHog
special person properties '\$email' / '\$name'. The frontend already
used the correct keys; the backend identify (sole source for users
who sign up but never log in) would have populated only custom
properties, leaving the built-in person profile email/name blank
in the PostHog UI.
2. handleSubscriptionDeleted now short-circuits when plan_id is already
null. Stripe re-delivers webhooks on transient failures, and the
prior version would dispatch a duplicate 'subscription.cancelled'
event and re-flush the (already empty) Pennant cache on each retry.
3. useTracking composable called posthog.capture directly, bypassing
the new enabled gate. While posthog-js queues calls before init
(so no events leaked over the network in self-hosted mode), the
buffer grew unbounded and would fire all queued events in bulk if
init was ever called. Replaced with a gated captureEvent helper
exported from posthog.ts.
Plus: drop the now-trivial 'updating non-plan fields does not flush
the pennant cache' test (no observer to test against), refresh stale
doc comments referencing the removed SyncUserToPostHog filename, and
add a Bus::assertNotDispatched check to the deletion-idempotency test.
Self-hosted installs that inherited POSTHOG_API_KEY from an example or
older deploy were still seeing SyncUser/SendEvent jobs run because the
gate was based on the api key alone. Switches the gate to an explicit
'services.posthog.enabled' flag (env: POSTHOG_ENABLED, default false)
and requires both enabled=true AND api_key for tracking to fire.
Backend gating:
- PostHogService::isEnabled() — single static helper used everywhere.
- AppServiceProvider::configurePostHog — skips PostHog::init when off.
- CreateUser::execute — does not enqueue SyncUser when off.
- SyncUser::handle, TrackBilling::handle, SendEvent::handle — early
return before any DB query so the queue worker does no work.
Frontend gating:
- New VITE_POSTHOG_ENABLED env var mirrored from POSTHOG_ENABLED.
- initializePostHog, syncPostHogContext, capturePageview all gated.
Tests updated to set both flags on the happy path; adds explicit
'CreateUser does not dispatch SyncUser when PostHog is disabled'.
Deploy note: the trypost.it cloud .env must set POSTHOG_ENABLED=true
before this branch is merged or analytics will go dark.
Passes the account's @username as confirmText to ConfirmDeleteModal so
the user has to type it to enable the destructive action. Mirrors the
pattern already used elsewhere in the app for high-impact deletes.
Drops the abort_if guard that blocked switching from a yearly billing
cadence to monthly. The product decision was reversed — users should
be free to move in either direction without going through support.
Removes the corresponding 'swap blocks yearly to monthly downgrade'
test.
UpgradeDialog was reading 'currentPlan.stripe_yearly_price_id' from
'auth.plan', but auth.plan is built by AuthPlanResource which only
exposes {id, slug, name, interval} — the price ids are absent. The
'isOnYearly' computation always compared against undefined and returned
false, so users on a yearly subscription saw the dialog as if they were
on monthly: no current-plan badge match, the toggle was forced visible,
and the wrong price/billing label was shown.
Switches both UpgradeDialog and the Billing settings page to read
auth.plan.interval directly (the authoritative field already present
in shared props), drops the now-unused 'currentPriceId' from the Inertia
middleware and the Auth type. Yearly is now the default selection on
dialog open and the toggle stays visible regardless of current cadence.
Two issues from review:
1. BillingController::checkout was setting plan_id immediately after
creating the Stripe Checkout session, before the user actually paid.
If the user abandoned checkout, the account ended up with a plan it
never paid for. Plan activation is now driven exclusively by the
customer.subscription.created webhook, which fires only after a
successful payment.
2. The 'if (\$account->wasChanged('plan_id')) { ... }' guards around
forgetPlanFeatureCache() were tautological — Eloquent's update()
already short-circuits when nothing changed, and Pennant forget()
is idempotent, so an extra cache clear when the plan didn't move
is harmless. Removing the guards keeps the listener and swap path
readable.
Replaces the implicit Account::booted() observer with an explicit
Account::forgetPlanFeatureCache() method called from each plan_id
mutation site (StripeEventListener x3, BillingController x2). Self-hosted
installs naturally never reach any of these callsites — Stripe webhooks
do not fire and the billing controllers redirect to /calendar before any
plan mutation happens — so the Pennant flush is now guaranteed to be a
cloud-only operation.
Adds integration coverage proving the full chain webhook -> plan_id
update -> Pennant flush -> next Feature::value resolves against the new
plan limit.
- New BillingEvent enum replaces 'subscription.{created,updated,cancelled}'
strings across StripeEventListener, TrackBilling and tests.
- SendEvent now takes (method, payload) directly instead of an array of
single-call shapes — overhead with no batching benefit.
- PostHogService consolidates the 3 api-key short-circuits into shouldSend().
- SyncUser eager-loads currentWorkspace.withCount('socialAccounts') and
drops the redundant posts_count from the workspace group identify.
- Frontend Usage interface centralised in resources/js/types — was
duplicated in posthog.ts and useFeatureAccess.ts.
- posthog.init moved out of module-import side-effect into
initializePostHog() called explicitly from app.ts.
- SyncUserTest cleans up the convoluted assertion that merged
$job->calls with Queue::pushed().
- Drop tests/Feature/StripeEventListenerTest.php (orphan, fully covered
by tests/Feature/Listeners/StripeEventListenerTest.php).
- Revert .github/FUNDING.yml to match origin/main.
Posts have no plan-quota gating, so a brief staleness on the count is
acceptable. Avoids the heavy aggregate query on every authenticated
request. Empty-account case skips cache writes entirely.
The previous version of `handleSubscriptionUpdated` and
`handleSubscriptionDeleted` were no-ops, so a plan swap or cancellation
on Stripe never reflected in `accounts.plan_id`. Mirrors sendkit's
listener structure and adds full coverage.
Listener:
- `handleSubscriptionCreated` / `handleSubscriptionUpdated` capture the
previous plan name, resolve the new plan from the subscription items'
price ids, and update `account.plan_id` if it changed. Pennant
feature caches are forgotten automatically by `Account::booted()`.
- `handleSubscriptionDeleted` clears `account.plan_id` so the UI and
authorization checks reflect "no plan" instead of keeping the
previous one attached. Idempotent.
- Helpers extracted: `resolvePlanFromSubscriptionItems(payload, account)`
(pure resolver, logs a warning when no plan matches) and
`trackPlanChange(account, event, previousPlan, payload)` (delegates to
the queued `TrackBilling` job).
- Restored the `match($type) => handler...` shape with explicit
protected handler methods for each subscription type.
TrackBilling:
- Adds optional `?string $previousPlan` constructor param. Forwarded as
`previous_plan` event property so PostHog funnels can tell upgrade
from downgrade from cancellation.
Tests:
- New: subscription updated/created sync `plan_id` from price ids
(monthly + yearly), idempotent when price already matches, ignored
when price ids are unknown, deletion clears plan_id, deletion
idempotent when already null. Tests override the seeded plans'
Stripe price ids inline so they don't depend on `.env.testing`
having `STRIPE_*_MONTHLY/YEARLY` set.
- New: previousPlan is forwarded to `TrackBilling` for updated and
deleted, and is null for first-activation.
- TrackBilling test: covers the previous_plan property in capture
payload (both supplied and default-null cases).
Suite: 1438 passed (+11).
Plus a tiny cleanup: replaced the `'PostHog\\SendEvent: ...'`
namespace-look-alike in the SendEvent log warning with the cleaner
`'PostHog SendEvent: ...'`.
Reorganises PostHog plumbing under `App\Jobs\PostHog` and extracts the
Stripe billing capture out of `StripeEventListener` into its own job.
Adds the missing test coverage that was promised but not delivered in
the previous commit.
Code changes:
- Move `app/Jobs/SendPostHogEvent.php` → `app/Jobs/PostHog/SendEvent.php`
(low-level dispatcher).
- Move `app/Jobs/SyncUserToPostHog.php` → `app/Jobs/PostHog/SyncUser.php`
(high-level user/account/workspace sync).
- New `app/Jobs/PostHog/TrackBilling.php` that owns the
capture('subscription.*') + SyncUser re-dispatch flow. Receives
account id + event name + payload, runs on the `posthog` queue.
- `StripeEventListener` slims down to a switch table mapping Stripe
event types to PostHog event names and dispatches `TrackBilling`. No
more inline tracking logic in the listener.
- `resources/js/posthog.ts` now owns `syncPostHogContext(page)` and
`capturePageview()`. `resources/js/app.ts` imports them — no behaviour
inlined in the bootstrap.
- `app/Services/PostHogService.php` and `app/Actions/User/CreateUser.php`
updated to the new namespaces.
Tests added/updated:
- `tests/Feature/Jobs/PostHog/SyncUserTest.php` — identify/group payload
shape, account metrics, workspace skip when none, queue assignment,
no-op without api key.
- `tests/Feature/Jobs/PostHog/TrackBillingTest.php` — capture payload,
SyncUser re-dispatch, missing-account/owner handling, api key gate.
- `tests/Feature/Jobs/PostHog/SendEventTest.php` — moved from
`tests/Feature/SendPostHogEventTest.php` and updated to new namespace.
- `tests/Unit/PostHogServiceTest.php` — adds coverage for the
account-aware capture (auto-attached `\$groups.account`, `account_id`,
`plan`) and the no-account branch.
- `tests/Feature/Listeners/StripeEventListenerTest.php` — replaces the
old inline-PostHog assertions with `Bus::fake([TrackBilling::class])`
and verifies the listener dispatches TrackBilling with the right
account id + event name for each subscription type, and skips
non-subscription event types.
- `tests/Feature/Actions/User/CreateUserTest.php` — verifies signup
dispatches `SyncUser` with the new user id.
Suite: 1427 passed (+20 net new, including the previous round of
metrics-related tests).
Wire PostHog identify + dual-group context across the stack so events
land on the right person and account/workspace groups, with counts kept
fresh by Inertia navigations rather than per-domain triggers.
- New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised
high-level sync — identifies the user and group-identifies their
account + current workspace using `$account->usage()` so the metrics
reuse the same source of truth Inertia ships in shared props.
- `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on
signup instead of calling `PostHogService` inline. Keeps the action
fast and routes everything through the queue.
- `app/Listeners/StripeEventListener.php`: webhook now captures
`subscription.created`/`updated`/`cancelled` against the account
owner profile (with `account` group auto-attached) and re-dispatches
`SyncUserToPostHog` so plan/has_active_subscription/is_on_trial
refresh after Stripe state changes.
- `app/Services/PostHogService.php`: `capture()` accepts an optional
`Account` that auto-attaches `$groups.account`, `account_id`, and
`plan` properties. Each public method short-circuits when
`POSTHOG_API_KEY` is unset so self-hosted installs are unaffected.
- `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage
shape (combined `withCount(['socialAccounts','posts'])` query) so
posts count is part of the same payload Inertia already ships.
- `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the
queued PostHog jobs actually drain in production.
- `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls
it on boot AND on every Inertia navigation, reading the fresh
`usage` props. This:
- Refreshes account group counts (workspaces, social accounts,
posts, members, credits) without per-domain triggers.
- Resolves the workspace-switch case where `setup()` does not
re-run but `navigate` fires with the new `auth.currentWorkspace`.
- Captures the initial `$pageview` so the first page of a session
is no longer dropped.
- `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on
logout so a follow-up login on the same browser doesn't keep events
attributed to the previous user.
- `resources/js/composables/useFeatureAccess.ts`: TS `Usage`
interface gains `postCount`.
- `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new
usage shape. Full suite: 1407 passed.
Hierarchy aligned with the domain model: person = User,
group `account` = billing/plan parent, group `workspace` =
collaboration child (carries `account_id` for drill-down).
- Billing settings page restructured around the design system: dropped
the 280px-label / 1fr-content split for stacked HeadingSmall sections;
current plan rendered as a hero card with display-font name, price,
amber sticker tile, and an inline primary "Change plan" CTA; payment
method consolidated into a single sticker row with a violet card-icon
tile and the manage CTA on the same line; empty payment-method state
handled. Invoices keep the sticker-row treatment.
- Upgrade dialog mirrors Subscribe.vue end-to-end: planTones backgrounds
per slug, ⭐ POPULAR / CURRENT badges absolute-positioned, ink-bordered
rounded-full CTA pill, sticker check icons, "Everything in {plan}"
copy, IconInfoCircle tooltip on credits, yearly/monthly toggle pill
with rotating amber save-months badge. While a request is in flight
every plan button is disabled and the active one shows IconLoader2
spinner.
- Account model gains `displayablePaymentMethod()` returning the
card array used by the UI. Resolves the customer-level default first,
falls back to the first attached payment method (Stripe Checkout trials
anchor the card to the subscription rather than the customer, so the
customer-level lookup returns null even when a card exists).
- i18n: added `billing.subscription.expires_on` and `no_payment_method`
in en/pt-BR/es.
- Replace global AppHeader slots with in-page headers on Calendar, Edit,
and Show pages — full-width canvas with the page-specific controls
living in their own ink-bordered bar.
- Remove sidebar collapse mechanism: drop Cmd+B keyboard shortcut, force
defaultOpen=true (ignore persisted cookie), make toggleSidebar a no-op
on desktop. Sidebar is permanent on desktop, sheet behavior preserved
on mobile.
- Redesign Tabs to match the design system: each TabsTrigger renders as
a standalone sticker button (border-2 + rounded-md + shadow-xs), with
amber-200 active state mirroring the "POST" badge from post-templates.
- Show page layout: cap preview Card to max-w-xl, preserve natural
aspect for single-image posts (object-contain, max-h-[480px]), drop
redundant stats grid, swap header sides (Back button left, status +
date right with date first).
- Assets: bump GalleryBrowser search inputs to h-12 for comfortable
browsing across uploads, Unsplash, and Giphy tabs.
- Fix PostPlatformMetrics loading-forever bug: hoist useHttp out of the
onMounted callback into setup, and read the response array directly
(backend returns the array at root, not under .data).
- i18n: rename posts.show.back_to_posts → posts.show.back; remove unused
posts.show.summary.* keys.
Backend exposes account and plan as proper Inertia resources so any
page can read them off shared props:
- AuthAccountResource — id, name, created_at
- AuthPlanResource — id, slug, name, interval (derived from the active
Cashier subscription's stripe_price)
Both wired into HandleInertiaRequests::share so `auth.account` and
`auth.plan` are available everywhere.
Frontend pushes app + identity context to GTM's dataLayer on every
page load via initializeDataLayer (resources/js/datalayer.ts), and
useTracking gets begin_checkout/purchase wired in the billing flow.
The pushes are no-ops without GTM configured (the array still exists
in memory, nothing reads it). Self-hosted instances without GTM_ID
incur zero error noise. The GTM partials in app.blade.php were
already conditional on `config('services.gtm.id')`.
Processing.vue polls `auth` alongside `subscriptionActive` so
auth.plan.interval is fresh once the Stripe webhook creates the local
Subscription row (it doesn't exist yet at the initial render). The
watch fires only on the false → true transition; an onMounted
fallback redirects users that land on the page with an already-active
subscription without re-firing trackPurchase.
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.
Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.
- routes/auth.php returns to its original state — redirect + callback
both back inside the `guest` group.
- routes/app.php gains a paired callback route at
/settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
connectProviderCallback override Socialite's redirectUrl so the
round-trip stays on the connect-flow URL. The Auth::check() branch
in the auth controllers is gone.
The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.
Splits the flows by intent:
- New `app.authentication.connect-provider` route in the auth group,
handled by the settings controller (where it sits next to
disconnect-provider). Replaces the OAuth signup link as the
Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
provider, since OAuth apps only register one) and gain a single
Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
to a different user; otherwise sets it on the current user and
redirects back to settings with a flash message.
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):
- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
the register/redirect GET, retrieves them on the POST/callback —
surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer
Adds GitHub as a second OAuth provider:
- GitHubController mirroring the Google one (now renamed from
SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
on Login/Register, rendering each enabled provider plus a single
"or continue with" divider
UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
Adds POST /api/posts/{post}/media for direct file (multipart) upload
and renames the existing URL-based flow to /api/posts/{post}/media/from-url
so the path matches HTTP semantics (POST <resource>/media expects a file
body, not JSON URLs).
The multipart action validates type against the post's enabled platforms
(image rejected on TikTok-only posts), enforces per-type size caps, and
reuses Workspace::addMedia + Post::appendMedia. URL-based attaching is
unchanged behaviorally — only the route name and controller method are
renamed for symmetry. The MCP AttachMediaFromUrlTool was already named
correctly and needs no changes; binary upload via MCP is a host-protocol
limitation that no MCP server (including Postiz) supports.
MediaAttacher had grown into a 180-line class with four private
methods doing different things — download, validation, storage,
and post-state mutation. Two of those (lock-and-merge into the
JSON media column, intersection of allowed media types per
platform) are post concerns, not network concerns.
Move them onto the Post model:
Post::allowedMediaTypes(): array<Type>
The intersection of media types accepted by every enabled
platform. Used to be inlined as MediaAttacher::allowedMediaTypesFor.
Post::appendMedia(array $items): void
Lock-then-merge into the post's media[] JSON column so
concurrent writers don't overwrite each other. Used to be
MediaAttacher::mergeIntoPostMedia.
MediaAttacher now reads cleanly:
attachFromUrls — orchestrate (loop URLs, batch the appendMedia)
attachOne — download → validate type/size → handoff
download — Http::sink + progress abort, returns DTO
Drops to ~120 lines with no nested concerns. The streaming progress
abort uses the same throw-RuntimeException pattern, but now
contained in download() instead of leaking into the orchestrator.
Previous attempt dropped both video/quicktime and video/webm citing
narrow platform support. Re-investigated:
- Modern .mov files (iPhone recordings, screen captures) are ISO BMFF
containers — the same format MP4 uses. Social platforms decode them
like MP4 even when PHP's mime_content_type() reports
'video/quicktime'. Postiz quietly accepts MOV via this same trick:
their file-type lib detects the magic bytes as 'video/mp4' and
renames the upload before storage.
- WebM is genuinely incompatible (Matroska + VP8/VP9 stack) — X / IG /
TikTok / FB / Pinterest / Bluesky / Threads all reject it.
So allow MOV, keep WebM out. Rejecting MOV would force every iPhone
user to transcode before uploading — real UX cost for no gain since
the publishers handle it.
Two regex pieces in StoreChunkedAssetRequest, both replaced by more
fitting tools:
1. Parsing 'Content-Range' header — preg_match with capturing groups
and $matches[1..3] indexing replaced by sscanf, which is
purpose-built for parsing structured strings with a known format:
$parsed = sscanf($header, 'bytes %d-%d/%d') ?: [];
$start = $parsed[0] ?? null;
No regex, no $matches array, no manual int casts (sscanf returns ints
for %d).
2. Validating filename suffix — regex:/\.(jpe?g|png|gif|webp|mp4)$/i
replaced by Laravel's built-in 'ends_with' rule, with extensions
derived from the MediaType enum:
$allowedSuffixes = collect([Image, Video])
->flatMap(fn (MediaType $t) => $t->extensions())
->map(fn ($ext) => '.'.$ext)
->all();
For the case-insensitive part, normalize the filename to lowercase
in prepareForValidation so 'IMG_1234.JPG' matches '.jpg' naturally.
Added MediaType::extensions() — single source of truth for filename
suffixes (jpg/jpeg/png/gif/webp + mp4/mov/webm). Mirrors
allowedMimeTypes() for callers that validate by name instead of MIME.