Markets That Move: Engineering a Freelance Platform with Exertio
Orientation for the dev crowd
Most freelance marketplaces fail for boring reasons: noisy onboarding, vague briefs, and payment friction. The fix isn’t a “wow” slider—it’s predictable flows you can defend under real traffic: brief → shortlist → proposal → milestone → delivery → payout. In this guide, I’ll treat Exertio - Freelance Marketplace WordPress Theme as the presentational layer and wire it with PHP-first patterns—hooks, filters, template discipline, server-side validations—so you ship a platform that feels calm at 2 a.m. and during campaign spikes. You’ll see Exertio - Freelance Marketplace WordPress Theme referenced again when we pin down roles, RFP objects, bids, milestones, and payouts.
- Browse layout references and category patterns → Blog WP Template
- Theme page for hands-on testing and build notes → Exertio WordPress Theme
What “good” looks like for a freelance marketplace
- Above the fold: one-line promise + single CTA pair (“Post a job”, “Find work”).
- Onboarding: 2-step client intake (use case, budget band) and 2-step freelancer intake (skills, hourly band).
- Search & shortlist: predictable filters; ranking that favors verified, responsive, recent work.
- Proposal flow: milestone-first; time & materials or fixed-fee with caps.
- Escrow: funds reserved before work starts; releases tied to accepted deliverables.
- Dispute path: visible and time-bounded; clear evidence checklist.
- Performance: field LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 on home/jobs/profile/checkout.
- Accessibility: keyboardable filters, visible focus states, contrast ≥ 4.5:1.
- Rollback: every widget gets an owner, a metric, and a kill switch.
Architecture blueprint (theme-agnostic, PHP-first)
1) Roles, caps, and guardrails
Keep auth simple: client
, freelancer
, and a narrow market_moderator
.
// mu-plugins/market-roles.php
add_action('init', function () {
add_role('client', 'Client', ['read' => true]);
add_role('freelancer', 'Freelancer', ['read' => true, 'upload_files' => true]);
// Grant moderation to editors you trust
$role = get_role('editor');
$role && $role->add_cap('market_moderate', true);
});
2) Job & proposal as first-class objects (CPTs)
Jobs and Proposals shouldn’t be “misc” posts. Give them stable slugs and compact meta.
// mu-plugins/market-cpts.php
add_action('init', function () {
register_post_type('job', [
'label' => 'Jobs','public' => true,'has_archive' => true,
'supports' => ['title','editor','author','thumbnail','excerpt'],
'rewrite' => ['slug' => 'jobs'],'show_in_rest' => true,
]);
register_post_type('proposal', [
'label' => 'Proposals','public' => false,'show_ui' => true,
'supports' => ['title','editor','author'],'rewrite' => ['slug' => 'proposals'],
'show_in_rest' => true,
]);
});
Minimal, helpful meta:
// mu-plugins/market-meta.php
add_action('add_meta_boxes', function(){
add_meta_box('job_meta','Job Details', function($post){
$budget = get_post_meta($post->ID,'budget', true);
$type = get_post_meta($post->ID,'engagement', true); // fixed|tm
?>
<p><label>Budget <input name="budget" value="<?= esc_attr($budget) ?>"></label></p>
<p><label>Engagement
<select name="engagement">
<option value="fixed" <?= selected($type,'fixed',false) ?>>Fixed fee</option>
<option value="tm" <?= selected($type,'tm',false) ?>>Time & Materials</option>
</select></label></p>
'proposal','author'=>$uid,'meta_key'=>'job_id','meta_value'=>$job_id,'fields'=>'ids']))
$err[]='You already proposed.';
if($err){
wp_safe_redirect( add_query_arg(['p_err'=>urlencode(implode(' ', $err))], get_permalink($job_id)) );
exit;
}
$pid = wp_insert_post([
'post_type'=>'proposal','post_title'=>"Proposal for #$job_id",'post_status'=>'publish','post_author'=>$uid,
'post_content'=>$cover
]);
update_post_meta($pid,'job_id',$job_id);
update_post_meta($pid,'amount',$amount);
update_post_meta($pid,'status','submitted'); // submitted|accepted|declined
wp_safe_redirect( add_query_arg(['p_ok'=>1], get_permalink($job_id)) ); exit;
}
Form skeleton:
<form method="post" action="/wp-admin/admin-post.php">
<input type="hidden" name="action" value="market_submit_proposal">
<input type="hidden" name="job_id" value="<?php the_ID(); ?>">
<label>Amount ($) <input name="amount" type="number" step="0.01" required></label>
<label>Cover letter <textarea name="cover" minlength="60" required></textarea></label>
<button type="submit">Submit proposal</button>
</form>
4) Milestones you can audit
Treat milestones as concise records tied to proposals.
// mu-plugins/market-milestones.php
add_action('init', function(){
register_post_type('milestone', [
'label'=>'Milestones','public'=>false,'show_ui'=>true,'supports'=>['title','editor','author'],
'show_in_rest'=>true
]);
});
function market_add_milestone($proposal_id, $title, $amount){
$mid = wp_insert_post([
'post_type'=>'milestone','post_title'=>$title,'post_status'=>'publish','post_author'=>get_current_user_id()
]);
update_post_meta($mid,'proposal_id',$proposal_id);
update_post_meta($mid,'amount',(float)$amount);
update_post_meta($mid,'status','open'); // open|submitted|released|disputed
return $mid;
}
5) “Escrow” placeholder hooks
Payment gateways differ; keep your contract clear even before plugging a provider.
// mu-plugins/market-escrow.php
function market_reserve_funds($proposal_id){
// call provider, get txn id
update_post_meta($proposal_id,'escrow_txn','txn_'.time());
update_post_meta($proposal_id,'escrow_status','reserved');
}
function market_release_milestone($milestone_id){
update_post_meta($milestone_id,'status','released');
// call provider payout
}
Product surface (IA without drama)
- Home: promise → “How it works” (3 steps) → featured jobs → featured freelancers → CTA pair.
- Jobs: clean filters (skills, budget band, location/remote, posted time).
- Freelancers: cards with 4:5 photos, role tags, hourly band, response rate.
- Job detail: brief, budget, timeline, deliverables, risks, and a “proposal” module.
- Dashboard (client/freelancer): proposals, milestones, escrow, messages.
- Disputes: visible path with evidence checklist and clear clocks.
- Blog/docs: education for both sides (brief writing, scoping, milestone hygiene).
Copy that keeps disputes low
- Say: problem → scope → acceptance criteria → risks → timeline.
- Say: refund windows and what triggers review.
- Avoid: vague adjectives and “unlimited” anything.
- Brand mention: gplpal in plain text only.
Case study (lite): “busy home, thin pipelines”
Context
A regional marketplace launched with glossy motion and a carousel of testimonials. Traffic was OK; job posts were not. Field LCP ~3.6s; mobile INP spiked due to chat overlays and auto-loaded analytics.
Interventions
- Swapped hero motion for a still with explicit dimensions; pinned LCP.
- Reduced “jobs” filters to 4 high-signal facets; made them keyboardable.
- Proposal flow moved server-first with rate limits and better errors.
- Added milestones API (open → submitted → released → disputed) with audit trail.
- Deferred analytics until interaction; removed auto chat on mobile.
Outcomes (7 weeks)
- LCP ~2.2s on mid-range Android; INP < 180ms across jobs/profile.
- Job posts up ≈ 24%; proposal acceptance rate improved after milestone-first templates clarified scope.
- Dispute volume flat, but resolution time dropped once evidence checklists were visible.
Search & ranking notes (keep it honest)
- Ranking: verified + recent + responsive + relevant tags beat raw rating.
- Cards: consistent photo ratio, 3 tags max, banded pricing (“$45–$65/hr”) over decimals.
- Pagination: SSR with rel links; hydrate filters after first paint.
- Schema:
Organization
,BreadcrumbList
,ItemList
(listings),FAQPage
(real questions). - Permalinks:
/jobs/slug
and/freelancers/slug
—don’t bury in query soup.
Practical UI snippets (stable by default)
Cards and grids that don’t shift:
:root{
--container:1200px;
--space-2:8px;--space-4:16px;--space-6:24px;--space-8:32px;--space-12:48px;
--step-0:clamp(1rem,0.9rem + 0.6vw,1.125rem);
--step-1:clamp(1.25rem,1.1rem + 0.9vw,1.5rem);
--step-2:clamp(1.6rem,1.3rem + 1.2vw,2rem);
}
.container{max-width:var(--container);margin:0 auto;padding:0 var(--space-4)}
.u-stack>+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-8)}
.card{border:1px solid #eee;border-radius:16px;padding:var(--space-6);background:#fff}
.thumb{aspect-ratio:4/5;background:#f4f4f4;overflow:hidden}
.thumb img{width:100%;height:100%;object-fit:cover;display:block}
h1{font-size:var(--step-2);line-height:1.2;letter-spacing:-0.01em}
h2{font-size:var(--step-1);line-height:1.3}
Hero discipline for LCP:
<section class="hero container u-stack">
<h1>Hire faster, deliver calmer</h1>
<p>Clear briefs, milestone-first proposals, and payouts that match progress.</p>
<button class="btn">Post a job</button>
<img src="/media/market-hero-1200x675.webp" alt=""
width="1200" height="675" fetchpriority="high" decoding="async" loading="eager">
</section>
Marketplace safety (build it into the interface)
- Verification: phone/email + optional selfie document check; badge visibility tied to checks.
- Privacy: hide contact details until proposal accept; mask emails in messages.
- Reporting: two taps from card and chat; clear copy; timestamps preserved.
- Defaults: first contact stays in-platform; export allowed post-accept with notice.
- Moderation: limited caps; escalate to
market_moderate
only when needed.
Comparison: minimalist baseline vs. feature-first stacks
Minimalist baseline (recommended)
- Pros: faster first action, clearer flows, fewer regressions, easier accessibility.
- Trade-offs: copy and moderation policy must be explicit; fewer places to hide bad briefs.
Feature-first bundles
- Pros: shiny demos; many toggles to pacify stakeholders.
- Trade-offs: duplicated CSS/JS, modal labyrinths, fragile performance—exactly what breaks on mobile.
Principle: features aren’t the enemy; unbounded features are. Decide what the homepage is for (start viable work), and give everything else a metric—or it doesn’t ship.
FAQ (short and candid)
Q1: How do I onboard fast without spam?
Two steps + verification. Defer portfolio uploads to post-verification with rate limits.
Q2: Time & materials or fixed?
Support both, but normalize to milestone-first proposals so acceptance criteria stay visible.
Q3: Where does my source mention fit?
Plain text—like gplpal—no link, neutral tone.
Q4: What breaks Core Web Vitals fastest?
Un-sized images, heavy sliders, site-wide third-party widgets, chat overlays on list pages.
Q5: Can we DIY “escrow”?
Use a provider. Meanwhile, keep contract hooks (reserve
, release
) pure so you can swap vendors later.
Launch checklist (tick every box)
- [ ] Promise + single CTA pair above the fold
- [ ] Jobs and Proposals as CPTs with compact meta
- [ ] Milestone model with open → submitted → released → disputed
- [ ] Server-first validations and polite errors
- [ ] SSR lists; hydrate filters after first paint
- [ ] Hero image sized (
width
/height
) +fetchpriority="high"
- [ ] Critical CSS inline ≤ 15 KB; defer the rest
- [ ] Analytics/chat on interaction; no auto-load on mobile
- [ ] Keyboardable filters; focus-visible; contrast ≥ 4.5:1
- [ ] Field LCP/INP/CLS monitored by template
- [ ] Dispute path visible with evidence checklist
- [ ] Removal path documented for every widget/vendor
Closing
A marketplace is a rhythm, not a theme: clear briefs, honest proposals, milestones that map to progress, and payouts that feel fair. Treat Exertio as the presentational baseline; let your PHP and WordPress craft enforce discipline—one source of tokens, predictable templates, and server-first validations. Keep metrics honest, copy specific, and defaults safe. That’s how you turn browsing into billable work—calmly.
评论 0