Hooks, Slots & Registry
This page is the contract between core and plugins. Every entry below exists in Xilancer 4.3.0. Payloads are scalars and arrays; models, requests, passwords, OTPs, and tokens are never passed.
Conventions
- Actions are
noun.verbin the past tense:order.paid,job.created. - Filters are
noun.dataordomain.name:user.create_data,checkout.price.calculate. - Slots are
area.element:order.details_after. - Registry domains are plural-ish nouns:
gateway,admin.menu. - Priorities are integers. Lower runs first. Default is 10. Ties are FIFO.
- Actions fire after the database write.
- Data filters run before create or update, and the allow-list is re-asserted after.
Registering
// Filter
$this->ctx->hooks->addFilter('checkout.price.calculate', $callback, 10);
// Action
$this->ctx->hooks->addAction('order.paid', $callback, 10);
// Slot
$this->ctx->slots->register('order.details_after', 'my-plugin.panel', $renderer, 10);
// Registry
$this->ctx->registry->register('admin.menu', 'my-plugin', $entry, 10);
// Remove
$this->ctx->hooks->removeFilter('checkout.price.calculate', $callback);
$this->ctx->hooks->removeAction('order.paid', $callback);
$this->ctx->slots->remove('order.details_after', 'my-plugin.panel');
$this->ctx->registry->remove('admin.menu', 'my-plugin');
remove* compares closures by object identity, strings by value, and [object, method] pairs by
object and method name. Register the same callable you want to remove.
Filters
A filter receives a value and optional context, and must return the same type. If no plugin handles
the hook, plugin_apply_filters() returns the input unchanged.
Core filters
| Hook | Value | Context | Notes |
|---|---|---|---|
checkout.price.calculate | float | {order_kind, freelancer_id, client_id, coupon_code} | Commission and fees are recomputed after. Web and API, standard and hourly. |
user.create_data | array create data | {via, user_type?, provider?, ref?} | via is web, api, social-web, or social-api. Allow-list: names, email, username, phone, country, state, city, experience, hourly rate. |
profile.update_data | array update data | {user_id, actor, source} | Allow-list: names, email, country, state, city, experience, hourly rate, phone. |
job.create_data | array create data | {via, user_id} | Allow-list: title, slug, category, duration, level, description, type, hourly rate, estimated hours, budget, meta_*. |
job.update_data | array update data | {via, user_id, job_id} | Same allow-list. |
project.create_data | array create data | {via, user_id} | Allow-list: category, title, slug, description, package titles, revisions, deliveries, charges, packages flag, meta_*. |
project.update_data | array update data | {via, user_id, project_id} | Same allow-list. |
proposal.create_data | array create data | {via, job_id, freelancer_id} | Allow-list: amount, duration, revision, cover letter. IDs, attachment, and currency are re-asserted. |
offer.create_data | array create data | {via, freelancer_id, client_id} | Allow-list: price, description, deadline, revision. IDs, revisions left, and status are re-asserted. |
menu.primary_items | decoded menu items | {menu_id} | Keep the item shape. |
search.jobs.results | list of result items | {filters} | Via plugin_filter_paginator. Non-array returns are ignored. Items may gain ai_match (0-100). |
search.projects.results | list of result items | {filters} | Same paginator contract. |
search.talents.results | list of result items | {filters} | Same paginator contract. |
job.recommended_freelancers | array of candidates | {job_id, job} | Return a reordered or reduced array. Items may gain ai_skill_match. |
client.recommended_freelancers | array of candidates | {user_id, job_id?, job} | Same item contract. |
freelancer.recommended_jobs | array of candidates | {user_id, freelancer} | Return a reordered or reduced array. |
project.related.results | array of items | {project_id, kind, item} | Reorder or filter related services. |
job.related.results | array of items | {job_id, kind, item} | Reorder or filter related jobs. |
project.create.allowed | bool, default true | {user_id, current_count, via} | Only called when a plugin registers it. |
project.max_price | float or null | {user_id, via} | null means no cap. Enforced against the highest package charge. |
commission.override | commission amount | {price, freelancer_id, source, subscription, global} | Not called when an individual commission exists. Order: individual, plugin, subscription, global. |
wallet.withdraw.holding_days | int days | {user_id} | null user means the global admin value. Stamped into wallet_history.available_at. |
wallet.withdraw.min_amount | float minimum | {user_id} | Enforced on web and API withdrawal. |
proposal.connect_cost | int cost | {user_id} | 0 makes the action free and skips the connect guard. |
offer.connect_cost | int cost | {user_id} | Display only today; no call site charges offers yet. |
support.priority | string priority | {user_id, role, department} | Applied before the ticket is created. |
security.violation_queue | {total, items} | {area?, limit?} | Supplies retained violations to the admin dashboard. |
security.restricted_words | list of strings | none | Replaces the core word list. |
security.scan | scan result | content and context | Allows AI or an external scanner to add findings. |
security.user_frozen | bool | {user_id} | Lets a plugin decide whether a user is frozen. |
security.login_blocked | string or null | credentials context | Return a message to block login. |
security.email_allowed | bool | Reject disposable or blocked domains. | |
security.access_blocked | string or null | request context | IP or country access control. |
security.rate_limit | array or null | route context | Return a rate-limit definition. |
seo.page_title | string | page context | Non-homepage title. |
seo.site_title | string | page context | Site title suffix. |
seo.meta_title | string | page context | Homepage meta title. |
seo.meta_description | string | page context | Meta description. |
seo.meta_keywords | string | page context | Meta keywords. |
seo.og_title | string | page context | Open Graph title. |
seo.og_description | string | page context | Open Graph description. |
seo.og_url | string | page context | Open Graph URL. |
seo.canonical_url | string | page context | Canonical URL. |
seo.extra_tags | string | page context | Extra HTML meta tags. Escape output. |
payment.complete | array result | {type, id, transaction_id} | Core completes at priority 10. Contribute a new type above 10 and pass others through. |
payment.complete.promotion | array result | {id, transaction_id} | Promotion activation handled by promote-freelancer. |
payment.charge | mixed | {gateway, args} | Return a response to handle checkout, or null to fall through. |
promotion.revenue | float | {year?, month?} | Completed promotion revenue for dashboards. |
promotion.transaction_fee | float | {price, type} | Promotion fee calculation. |
promotion.is_promoted | bool | {identity, type} | Whether an item is currently promoted. |
promotion.packages | packages | none | Active promotion packages for checkout. |
freelancer.level | rendered level | (freelancer_id, context) | Level rendering. |
freelancer.level2 | legacy level | (freelancer_id, context) | Legacy rendering. |
freelancer.level.api | API level | (freelancer_id, context) | API rendering. |
freelancer.level.data | {level_name, level_image} | (freelancer_id) | Data for FreelancerLevelService. |
freelancer.level.options | levels | none | Levels with rules for the talent badge filter. |
freelancer.level.rule | bool | (period) | Whether a level rule exists for a period. |
ai.suggest.offer | string or array | (context) | String in, improved string out; array in, array plus ai_improved, ai_tips, ai_source. |
ai.suggest.project | string or array | (context) | Same contract. |
ai.suggest.profile | profile array | none | Same array plus ai_score and ai_suggestions. |
Filter rules
- Always return the same type you received.
- Never mutate the input by reference; return a new value.
- Do not assume the context keys exist. Use
??defaults. - Do not throw. A throw is caught and the original value is used, which hides your change.
- Do not perform writes in a filter. Use an action for side effects.
Actions
Actions receive (id, payload). The payload is an array. It always includes the IDs needed to
re-query, never a model.
Users and auth
| Hook | Payload | When |
|---|---|---|
user.registered | {user_type, email} | User::created, covers web, API, and social |
user.login | {user_type, guard, via, is_new} | Successful login |
user.login_failed | {identifier, via} | Failed login; no passwords |
user.logout | {user_type, guard} | Before session teardown |
user.password_reset | {user_type?, via} | OTP password reset |
user.password_changed | {user_type, via} | Password change |
user.email_verified | {user_type?, via} | Email verification |
user.identity_submitted | {country_id, user_type} | ID submission; no images or numbers |
user.suspended | {user_type, refunded_order_ids} | Suspension |
user.unsuspended | {} | Suspension lifted |
user.status_changed | {status} | Active/inactive toggle |
profile.updated | changed fields | User::updated, covers all profile writes |
Jobs, projects, proposals, offers
| Hook | Payload | When |
|---|---|---|
job.created | {user_id, status, type} | After create |
job.updated | {user_id, status} | After update, including admin re-categorization |
job.status_changed | {user_id, on_off} | Open/close toggle |
job.approved / job.rejected | {user_id?, status} | Moderation result |
job.deleted | {user_id, title} | Snapshot, per ID in bulk |
project.created | {user_id, status} | After commit |
project.updated | {user_id, status} | After commit, including re-categorization |
project.status_changed | {user_id, project_on_off} | Availability toggle |
project.approved / project.rejected | {status, project_approve_request, reject_reason?} | Moderation result |
project.deleted | {user_id, title} | Skipped when the order-count guard blocks deletion |
proposal.created | {job_id, freelancer_id, client_id, amount} | After create |
proposal.rejected | {job_id, freelancer_id, client_id} | Client rejects |
offer.created | {freelancer_id, client_id, price} | After create |
Orders and checkout
| Hook | Payload | When |
|---|---|---|
order.created | {payment_status, client_id, price, payable_amount} | Order::created, all gateways and API |
order.checkout.initiated | {client_id, freelancer_id, total, gateway} | After create and milestones, before redirect |
order.paid | {client_id, price, payable_amount} | Order::updated when payment becomes complete |
order.accepted | {freelancer_id, client_id} | Freelancer accepts |
order.declined / order.cancelled | {action, price, payment_status} | One call, action in payload |
order.submitted | {milestone_id?, submit_id, client_id} | Work submitted |
order.milestone.activated | {} | Milestone becomes active |
order.revision.requested | {milestone_id?, submit_history_id?} | After the branch join |
order.milestone.approved | {milestone_id, freelancer_id, amount, is_final} | Final-milestone path |
order.completed | {freelancer_id} | Order reaches completed |
order.approved | {freelancer_id, client_id, payout, commission} | Non-milestone approval |
order.rated | {rater_id, rating} | Review submitted |
order.review_replied | {freelancer_id} | Freelancer replies |
order.reported | {reporter, report_id} | Report submitted |
order.held / order.unheld | {from_status} / {restored_status} | Admin hold |
order.manual_payment.approved | {client_id, freelancer_id} | Manual payment approved |
order.cancelled_by_admin / order.declined_by_admin | {price} | Admin cancellation or decline |
Wallet, subscription, commission
| Hook | Payload | When |
|---|---|---|
wallet.deposit.initiated | {user_id, amount, gateway} | Before money moves |
wallet.deposit.completed | {user_id, amount, gateway, transaction_id} | Commit point, all IPNs |
wallet.deposit.approved / wallet.deposit.rejected | {user_id, amount?} | Admin review |
wallet.credited_by_admin / wallet.debited_by_admin | {user_id, amount} | Admin adjustment |
wallet.withdraw.requested | {user_id, gross, net, fee} | After the transaction |
wallet.withdraw.updated | {user_id, old_status, new_status} | Approve, complete, or cancel |
wallet.held_for_order | {client_id, amount} | Hourly client lock |
subscription.activated | {user_id, plan_id?, gateway?, transaction_id?} | Free, wallet, and IPN commits |
subscription.purchase.initiated | {user_id, plan_id, gateway} | Manual pending |
subscription.manual_payment.approved | {user_id} | Admin approval |
subscription.deactivated / subscription.reactivated | {user_id, status} | Status change |
commission.global_updated | {type, charge} | Global commission change |
commission.user_updated | {type?, charge?} | Per-user change; null resets to global |
Other actions
| Hook | Payload | When |
|---|---|---|
request | request context | Plugin-provided; used by affiliate attribution |
cron.daily | none | Plugin-provided daily sweep |
security.log_activity | activity payload | Core delegates log writes to Security Manage |
security.notify_user | {user_id, subject, body} | Core sends the notification |
security.notify_admin | {subject, message} | Core notifies the admin |
security.violation_record | {scan, context} | A violation is detected |
promotion.click | (identity, type) | A promoted item is clicked |
promotion.impression | (identities, type) | Promoted items are shown |
freelancer.level.changed | {level_id, previous_level_id, level, previous_level} | A persisted level moves |
Action rules
- Actions fire after the write and outside transactions.
- An action that throws is caught by
plugin_do_action()and reported. Other listeners still run. - Do not assume ordering between unrelated actions.
- Payloads are snapshots. Re-query if you need current state.
- Keep actions fast. Queue heavy work.
Slots
Slots render HTML. Each renderer receives a context array and returns a string. A throwing renderer is skipped per renderer; the rest of the slot still renders.
| Slot | Context | Location |
|---|---|---|
checkout.coupon_field | {} | Checkout, posts coupon_code with the order form |
checkout.order_summary_after | reserved | Not placed yet |
order.success_after | {order_id} | Order success page |
order.details_after | {order_id, role} | Order details, client or freelancer |
job.ai_assist | {} | Client job create form |
job.proposal_box_after | {job_id} | Job details, outside the apply-state chain |
project.ai_assist | {} | Freelancer service create form |
projects.recommendation_cta | {} | Projects listing |
jobs.recommendation_cta | {} | Jobs listing |
talents.recommendation_cta | {} | Talents listing |
project.offer_box_after | {project_id} | Project details, outside the role/state chain |
offer.ai_assist | reserved | Not placed yet |
profile.completion | {user_id, role} | Profile settings, outside the form |
profile.sidebar_after | {user_id, is_own_profile} | Public profile sidebar, all viewers |
dashboard.referral_panel | {role, user_id} | Dashboard sidebar, client and freelancer |
dashboard.affiliate_panel | {user_id, role} | Dashboard sidebar |
admin.dashboard_widgets | {} | Admin dashboard, gate sensitive content with @can |
admin.order_details_after | {order_id} | Admin order details, below the grid |
admin.user_details_after | {user_id} | Admin user details, below the grid |
admin.assets | {} | Admin footer assets |
support.ticket_list_badge | {user_id, role} | Support ticket list title |
support.ticket_details_badge | {ticket_id, user_id, role} | Support ticket details header |
Slot rules
- Escape every value with
htmlspecialchars($value, ENT_QUOTES, 'UTF-8'). - Do not assume ambient Blade variables. Use the context array.
- Do not render a
<form>inside another form. Checkout coupon fields are the exception because the slot is placed inside the form on purpose. - Keep output small. A slot renders on every page view that reaches it.
- Return an empty string, not
null, when there is nothing to show.
Registry domains
Registry entries are structured data, not HTML. plugin_registry($domain) returns entries keyed by
ID, priority ordered.
| Domain | Entry shape |
|---|---|
gateway | {id, label, module, ipn_route, settings_prefix, enabled_option} |
checkout.adjuster | {id, label, adjust: callable(totals): totals} |
search.modifier | {id, apply: callable(query): query} |
auth.provider | {id, label, redirect: callable, callback: callable} |
admin.menu | {label, route, icon?, permissions?} |
dashboard.menu | {label, route, icon?, roles: list} |
widget | {title, render: callable(context): string} |
cloud_storage.sync_source | {id, model, path, pending: callable(): Builder} |
cloud_storage.driver | {id, label, disk, prefix, cloud, fields} |
Gateway example
$this->ctx->registry->register('gateway', 'mygateway', [
'id' => 'mygateway',
'label' => 'My Gateway',
'module' => 'MyGateway',
'ipn_route' => 'mygateway.ipn',
'settings_prefix' => 'mygateway',
'enabled_option' => 'mygateway_enabled',
]);
Core's PaymentGatewayList, IPN routing, and settings screen consume this. The plugin still needs a
payment.charge filter to start a checkout and a payment.complete call to settle it.
Dashboard menu example
$this->ctx->registry->register('dashboard.menu', 'my-plugin', [
'label' => 'My Reports',
'route' => 'my-plugin.reports',
'icon' => 'fa-solid fa-chart-line',
'roles' => ['freelancer', 'client'],
]);
Cloud storage driver example
$this->ctx->registry->register('cloud_storage.driver', 'my-driver', [
'id' => 'my-driver',
'label' => 'My Storage',
'disk' => 'my-driver',
'prefix' => 'mydriver',
'cloud' => true,
'fields' => [
'key' => ['label' => 'Access key', 'required' => true],
'secret' => ['label' => 'Secret', 'required' => true, 'secret' => true],
'region' => ['label' => 'Region', 'required' => true],
],
]);
Plugin-provided hooks
These are fired by plugins, not core. Core may listen to some of them.
| Hook | Type | Contract |
|---|---|---|
payment.complete | filter | Gateway verifies an IPN, then core completes wallet, order, subscription, or promotion |
payment.complete.promotion | filter | Promotion activation |
security.restricted_words | filter | Active restricted words |
security.log_activity | action | Activity log write |
security.notify_user | action | User notification, core sends it |
security.notify_admin | action | Admin notification, core sends it |
promotion.click | action | Record a promoted click |
promotion.impression | action | Record impressions |
promotion.revenue | filter | Promotion revenue for dashboards |
promotion.transaction_fee | filter | Promotion fee |
promotion.is_promoted | filter | Whether an item is promoted |
promotion.packages | filter | Active packages |
cloud_storage.sync_file | action | Request a file sync; core dispatches the job |
cloud_storage.driver | registry | Storage drivers |
cloud_storage.sync_source | registry | Models with syncable files |
freelancer.level* | filter | Level rendering and data |
freelancer.level.changed | action | Persisted level change |
ai.suggest.* | filter | AI drafting |
cron.daily | action | Daily plugin sweep |
request | action | Early request context for attribution |
Helpers
Core uses these. Plugins may call the read-only ones, but registering through PluginContext is
preferred.
| Helper | Purpose |
|---|---|
plugin_directory() | Absolute path to /plugins |
plugin_framework_available() | Framework bootstrap or class exists |
plugin_booted() | Framework booted in this process |
plugin_statuses() | Decoded statuses.json |
plugin_is_active($slug) | Activation check |
plugin_manifest($slug) | Decoded manifest or null |
plugin_resolve_slug($input) | Resolve slug, legacy name, directory, or display name |
plugin_apply_filters($hook, $value, ...$args) | Apply filters, safe fallback |
plugin_has_filter($hook) | Whether any plugin listens |
plugin_do_action($hook, ...$args) | Dispatch an action, caught |
plugin_render_slot($slot, $context) | Render a slot, errors isolated |
plugin_registry($domain) | All entries for a domain |
plugin_register($domain, $id, $entry, $priority) | Register from core |
plugin_gateways() | gateway registry entries |
plugin_gateway_declared($moduleOrId) | Whether a gateway plugin is active |
plugin_gateway_ipn_route($id, $fallback) | Registered IPN route name |
plugin_charge_payment($gateway, $args) | Run the payment.charge funnel |
plugin_filter_model_data($data, $hook, $allowed, $context) | Filter plus allow-list re-assert |
plugin_filter_paginator($paginator, $hook, $context) | Rerank a paginator collection |
cloud_storage_drivers() | Driver catalogue |
cloud_storage_cloud_ids() | Cloud driver IDs |
Dispatch semantics
Filter dispatch
$value = plugin_apply_filters('checkout.price.calculate', 100.0, $context);
- No listeners: returns
100.0. - Framework absent: returns
100.0. - Callback throws: caught,
report()called, returns the value from before that callback. - Callbacks run in priority order.
Action dispatch
plugin_do_action('order.paid', $orderId, $meta);
- No listeners: no-op.
- Callback throws: caught,
report()called, remaining callbacks still run. - Actions cannot change a value. Use a filter for that.
Slot dispatch
echo plugin_render_slot('order.details_after', ['order_id' => $id, 'role' => 'client']);
- No renderers: returns an empty string.
- One renderer throws: it is skipped, others render, error recorded.
- Output is concatenated in priority order.
Paginator
$paginator = plugin_filter_paginator($paginator, 'search.jobs.results', ['filters' => $filters]);
- Requires an object with
getCollection()andsetCollection(). - A non-array return is ignored.
- The same paginator instance is returned.
What core deliberately does not hook
| Area | Why | Alternative |
|---|---|---|
| Outbound mail | About 95 diffuse call sites, no funnel | Use order or view slots, or a webhook receiver |
| Password checks | Read-only checks are an oracle risk | Use login actions only |
| OTP set and resend | Spam and leak risk | Use the SMS plugin's own settings |
| Account delete | Destructive and asymmetric across web/API | Needs an explicit user.deleted decision |
Per-gateway charge_customer | About 30 branches across orders, wallet, subscriptions | Hook the commits: order.paid, wallet.deposit.completed, subscription.activated |
| Client-side job delete | Does not exist | Use admin delete actions |
| Form validation mutation | Validation runs on request data first | Use *.create_data to enrich |
| Social OAuth referral attribution | No ref channel through the OAuth state | Web and API password signups only |
| Subscription expiry | Passive, no transition | Use a scheduled check |
Adding a core seam
Plugin authors cannot add seams. If you need one, open a request with:
- The hook name and type (filter, action, slot, registry).
- The exact payload and types.
- The file and line where it should fire.
- Why an existing seam cannot do the job.
- Whether it should be gated by
plugin_has_filter().
Core-side checklist:
- Guard with
function_exists('plugin_do_action'). - Gate expensive payloads with
plugin_has_filter('<hook>'). - Actions after writes, outside transactions.
- Data filters before create/update, with an allow-list.
- Slots outside forms, tab groups, and JS-moved modals.
- Add the row to
plugins/HOOKS.mdwith file and line.
Last updated: October 21, 2026

