Xgenious/ docs

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.verb in the past tense: order.paid, job.created.
  • Filters are noun.data or domain.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

HookValueContextNotes
checkout.price.calculatefloat{order_kind, freelancer_id, client_id, coupon_code}Commission and fees are recomputed after. Web and API, standard and hourly.
user.create_dataarray 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_dataarray update data{user_id, actor, source}Allow-list: names, email, country, state, city, experience, hourly rate, phone.
job.create_dataarray create data{via, user_id}Allow-list: title, slug, category, duration, level, description, type, hourly rate, estimated hours, budget, meta_*.
job.update_dataarray update data{via, user_id, job_id}Same allow-list.
project.create_dataarray create data{via, user_id}Allow-list: category, title, slug, description, package titles, revisions, deliveries, charges, packages flag, meta_*.
project.update_dataarray update data{via, user_id, project_id}Same allow-list.
proposal.create_dataarray create data{via, job_id, freelancer_id}Allow-list: amount, duration, revision, cover letter. IDs, attachment, and currency are re-asserted.
offer.create_dataarray create data{via, freelancer_id, client_id}Allow-list: price, description, deadline, revision. IDs, revisions left, and status are re-asserted.
menu.primary_itemsdecoded menu items{menu_id}Keep the item shape.
search.jobs.resultslist of result items{filters}Via plugin_filter_paginator. Non-array returns are ignored. Items may gain ai_match (0-100).
search.projects.resultslist of result items{filters}Same paginator contract.
search.talents.resultslist of result items{filters}Same paginator contract.
job.recommended_freelancersarray of candidates{job_id, job}Return a reordered or reduced array. Items may gain ai_skill_match.
client.recommended_freelancersarray of candidates{user_id, job_id?, job}Same item contract.
freelancer.recommended_jobsarray of candidates{user_id, freelancer}Return a reordered or reduced array.
project.related.resultsarray of items{project_id, kind, item}Reorder or filter related services.
job.related.resultsarray of items{job_id, kind, item}Reorder or filter related jobs.
project.create.allowedbool, default true{user_id, current_count, via}Only called when a plugin registers it.
project.max_pricefloat or null{user_id, via}null means no cap. Enforced against the highest package charge.
commission.overridecommission amount{price, freelancer_id, source, subscription, global}Not called when an individual commission exists. Order: individual, plugin, subscription, global.
wallet.withdraw.holding_daysint days{user_id}null user means the global admin value. Stamped into wallet_history.available_at.
wallet.withdraw.min_amountfloat minimum{user_id}Enforced on web and API withdrawal.
proposal.connect_costint cost{user_id}0 makes the action free and skips the connect guard.
offer.connect_costint cost{user_id}Display only today; no call site charges offers yet.
support.prioritystring 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_wordslist of stringsnoneReplaces the core word list.
security.scanscan resultcontent and contextAllows AI or an external scanner to add findings.
security.user_frozenbool{user_id}Lets a plugin decide whether a user is frozen.
security.login_blockedstring or nullcredentials contextReturn a message to block login.
security.email_allowedboolemailReject disposable or blocked domains.
security.access_blockedstring or nullrequest contextIP or country access control.
security.rate_limitarray or nullroute contextReturn a rate-limit definition.
seo.page_titlestringpage contextNon-homepage title.
seo.site_titlestringpage contextSite title suffix.
seo.meta_titlestringpage contextHomepage meta title.
seo.meta_descriptionstringpage contextMeta description.
seo.meta_keywordsstringpage contextMeta keywords.
seo.og_titlestringpage contextOpen Graph title.
seo.og_descriptionstringpage contextOpen Graph description.
seo.og_urlstringpage contextOpen Graph URL.
seo.canonical_urlstringpage contextCanonical URL.
seo.extra_tagsstringpage contextExtra HTML meta tags. Escape output.
payment.completearray result{type, id, transaction_id}Core completes at priority 10. Contribute a new type above 10 and pass others through.
payment.complete.promotionarray result{id, transaction_id}Promotion activation handled by promote-freelancer.
payment.chargemixed{gateway, args}Return a response to handle checkout, or null to fall through.
promotion.revenuefloat{year?, month?}Completed promotion revenue for dashboards.
promotion.transaction_feefloat{price, type}Promotion fee calculation.
promotion.is_promotedbool{identity, type}Whether an item is currently promoted.
promotion.packagespackagesnoneActive promotion packages for checkout.
freelancer.levelrendered level(freelancer_id, context)Level rendering.
freelancer.level2legacy level(freelancer_id, context)Legacy rendering.
freelancer.level.apiAPI level(freelancer_id, context)API rendering.
freelancer.level.data{level_name, level_image}(freelancer_id)Data for FreelancerLevelService.
freelancer.level.optionslevelsnoneLevels with rules for the talent badge filter.
freelancer.level.rulebool(period)Whether a level rule exists for a period.
ai.suggest.offerstring or array(context)String in, improved string out; array in, array plus ai_improved, ai_tips, ai_source.
ai.suggest.projectstring or array(context)Same contract.
ai.suggest.profileprofile arraynoneSame array plus ai_score and ai_suggestions.

Filter rules

  1. Always return the same type you received.
  2. Never mutate the input by reference; return a new value.
  3. Do not assume the context keys exist. Use ?? defaults.
  4. Do not throw. A throw is caught and the original value is used, which hides your change.
  5. 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

HookPayloadWhen
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.updatedchanged fieldsUser::updated, covers all profile writes

Jobs, projects, proposals, offers

HookPayloadWhen
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

HookPayloadWhen
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

HookPayloadWhen
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

HookPayloadWhen
requestrequest contextPlugin-provided; used by affiliate attribution
cron.dailynonePlugin-provided daily sweep
security.log_activityactivity payloadCore 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

  1. Actions fire after the write and outside transactions.
  2. An action that throws is caught by plugin_do_action() and reported. Other listeners still run.
  3. Do not assume ordering between unrelated actions.
  4. Payloads are snapshots. Re-query if you need current state.
  5. 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.

SlotContextLocation
checkout.coupon_field{}Checkout, posts coupon_code with the order form
checkout.order_summary_afterreservedNot 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_assistreservedNot 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

  1. Escape every value with htmlspecialchars($value, ENT_QUOTES, 'UTF-8').
  2. Do not assume ambient Blade variables. Use the context array.
  3. Do not render a <form> inside another form. Checkout coupon fields are the exception because the slot is placed inside the form on purpose.
  4. Keep output small. A slot renders on every page view that reaches it.
  5. 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.

DomainEntry 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.

HookTypeContract
payment.completefilterGateway verifies an IPN, then core completes wallet, order, subscription, or promotion
payment.complete.promotionfilterPromotion activation
security.restricted_wordsfilterActive restricted words
security.log_activityactionActivity log write
security.notify_useractionUser notification, core sends it
security.notify_adminactionAdmin notification, core sends it
promotion.clickactionRecord a promoted click
promotion.impressionactionRecord impressions
promotion.revenuefilterPromotion revenue for dashboards
promotion.transaction_feefilterPromotion fee
promotion.is_promotedfilterWhether an item is promoted
promotion.packagesfilterActive packages
cloud_storage.sync_fileactionRequest a file sync; core dispatches the job
cloud_storage.driverregistryStorage drivers
cloud_storage.sync_sourceregistryModels with syncable files
freelancer.level*filterLevel rendering and data
freelancer.level.changedactionPersisted level change
ai.suggest.*filterAI drafting
cron.dailyactionDaily plugin sweep
requestactionEarly request context for attribution

Helpers

Core uses these. Plugins may call the read-only ones, but registering through PluginContext is preferred.

HelperPurpose
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() and setCollection().
  • A non-array return is ignored.
  • The same paginator instance is returned.

What core deliberately does not hook

AreaWhyAlternative
Outbound mailAbout 95 diffuse call sites, no funnelUse order or view slots, or a webhook receiver
Password checksRead-only checks are an oracle riskUse login actions only
OTP set and resendSpam and leak riskUse the SMS plugin's own settings
Account deleteDestructive and asymmetric across web/APINeeds an explicit user.deleted decision
Per-gateway charge_customerAbout 30 branches across orders, wallet, subscriptionsHook the commits: order.paid, wallet.deposit.completed, subscription.activated
Client-side job deleteDoes not existUse admin delete actions
Form validation mutationValidation runs on request data firstUse *.create_data to enrich
Social OAuth referral attributionNo ref channel through the OAuth stateWeb and API password signups only
Subscription expiryPassive, no transitionUse a scheduled check

Adding a core seam

Plugin authors cannot add seams. If you need one, open a request with:

  1. The hook name and type (filter, action, slot, registry).
  2. The exact payload and types.
  3. The file and line where it should fire.
  4. Why an existing seam cannot do the job.
  5. Whether it should be gated by plugin_has_filter().

Core-side checklist:

  1. Guard with function_exists('plugin_do_action').
  2. Gate expensive payloads with plugin_has_filter('<hook>').
  3. Actions after writes, outside transactions.
  4. Data filters before create/update, with an allow-list.
  5. Slots outside forms, tab groups, and JS-moved modals.
  6. Add the row to plugins/HOOKS.md with file and line.

Last updated: October 21, 2026

Still stuck?
Our support team is ready to help you get set up.
Get support