cswork / libraryOpen practice workspace

Interview guides · Artifax

Artifax Software Engineer Interview Guide

Artifax Software Engineer interview guide: legacy PHP/Laravel modernization, REST API and migration questions, AI workflow design and a 7-day plan.

The 60-second brief

Artifax builds software for managing venues and events, used by museums, festivals and performing arts centres. The Software Engineer role described by candidates sits on a mature PHP-based enterprise platform and has two threads: modernizing that platform and adding AI-enabled workflows to the product. Candidates describe the stack as PHP 8.2 or later with Laravel or CodeIgniter, TypeScript and React on the front end, AWS for deployment, and Docker and Kubernetes for orchestration, with RAG pipelines and vector databases listed as a bonus. The role is reported as remote-first, working with a Product Owner and the Head of Engineering, and owning a feature from design through deployment and monitoring.

This guide is for experienced engineers preparing for the Artifax Software Engineer loop. The requirements candidates describe include seven or more years of production experience, UK residency with the right to work (no visa sponsorship), and a cover letter with the application. The guide covers the six reported questions: refactoring a legacy PHP/Laravel codebase, a modernization story from your own career, REST API performance, database migrations in a long-lived application, AI tools in your daily workflow, and designing a secure AI-powered workflow. Candidates report little emphasis on competitive-programming algorithms, so the plan spends its time on design judgement, maintainable code and clear explanation.

Candidates describe an initial screening, then a technical assessment, then deeper technical interviews. No round count, order of individual interviews or per-stage format is reported, so confirm the details with your recruiter.

Focus areas

  • AI-enabled product development
  • LLM integration
  • Responsible AI / AI guardrails

Overview

The work behind this loop is two-sided. One side is keeping a long-running PHP platform stable while it is modernized piece by piece. The other is shipping AI-enabled features on top of it. The six reported questions follow that split: four sit under technical modernization (the Laravel refactor, your own modernization story, REST API performance, database migrations) and two under AI and innovation (your own AI tool usage, and designing a secure AI-powered workflow). Only one of the six is a pure behavioral prompt, but most of the others are answered best with a real project from your history.

Candidates report that the evaluation areas are refactoring strategy, testing habits for legacy code, dependency and library upgrades, agentic workflows for review and documentation, guardrails such as output validation and prompt security, and commercial impact. For preparation, that means building answers around concrete mechanisms. For refactoring, that is characterization tests, seams and incremental rollout. For migrations, it is a safe order of schema and code changes plus a rollback path. For AI, it is validation, data handling and fallback behaviour.

Candidates also report that algorithm drills matter less here than practical system design and maintainable, production-grade code. If the technical assessment is a take-home, a README that states your design decisions and trade-offs is a reported tip. Write that README habit into every practice exercise you do this week, and prepare to share your screen to walk through code or an architecture diagram.

Where to focus your preparation

Candidates describe a screening, a technical assessment and further technical interviews, but report no round count or per-stage format, so the guide treats them as one preparation block.

Preparation focus

Candidates describe a process that begins with an initial screening, moves to a technical assessment, and then continues into deeper technical interviews. Nothing is reported about how many interviews follow, how long each lasts or what each contains, so ask the recruiter whether the assessment is a take-home or live, whether you will write code during the interviews, and who you will meet. Whatever the format, the six reported questions are the best map of what to prepare. Modernization, API and migration questions reward answers built on a real project, with the reason you chose each approach over its alternatives. The AI questions reward a specific toolchain and a specific safeguard rather than general enthusiasm. If the assessment produces code, add a README that states your decisions and trade-offs, since that is a reported tip.

What to demonstrate

  • Hands-on coding that is clean, documented and maintainable, which is what a technical assessment produces
  • Judgement on modernizing a PHP/Laravel codebase safely: testing, incremental change and dependency upgrades
  • Architecture reasoning for REST APIs, long-lived databases and AI-enabled workflows, including why you picked one approach over another
  • How you use AI tools in development and apply guardrails to AI features in production

How to prepare

  • Write a one-page outline of one real modernization project: starting state, hurdles, the sequence of changes, how you protected live behaviour, and the result
  • Prepare a short answer on each of the Laravel refactor, REST API performance and database migration questions, each naming a concrete mechanism rather than principles
  • Write down your actual AI toolchain (for example Copilot or Cursor if you use them), what you delegate to it, and how you verify its output before merging
  • Set up a stable screen-sharing environment and rehearse walking through code or a diagram while talking, since candidates are told to expect that

Where candidates lose points

Answering the Laravel refactoring question with a plan to rewrite the module or the whole application

Describe an incremental path: pin current behaviour with characterization tests, introduce a seam, move one slice at a time behind a feature flag, and keep the old path until the new one has run in production. State what you would leave alone and why.

Telling a modernization story as a list of technologies (framework upgrade, containers, new library) with no obstacles, no decision of yours and no result

Structure it around the hurdles: what blocked you, the options you weighed, why you picked one, how you limited the risk, and what you measured afterwards. Keep it to what you personally decided.

Treating the database migration question as a list of schema commands, with no ordering between deploys, no handling of large tables and no rollback

Talk through the expand, backfill and contract sequence so old and new application code both work at every step. Cover how you backfill in batches without long locks, how you verify the data, and how you reverse each step if it fails.

Answering the AI questions with "I use Copilot or Cursor to go faster", or designing an AI workflow with only the happy path

Name what you delegate to an AI tool and what you check by hand, such as tests, review and secrets handling. For the enterprise workflow, cover input and output validation, customer data boundaries, authentication, logging, retries and a fallback when the model fails or is unavailable.

Submitting a technical assessment with working code but no README, or leaving out the cover letter the application requires

Include a README that states your design decisions and the trade-offs you made, as candidates are advised. Write the cover letter around a legacy system you modernized, since that is the experience the role asks for.

Questions & practice

Merge partitioned event streams into one ordered feed with bounded lateness

Hard · k-way merge · watermarks · out-of-order streams

The read-model service consumes 64 log partitions carrying about 4,000 events per second in total. Each partition is ordered within itself, but partitions drift by up to 30 seconds, and the activity feed must present a tenant's events in occurred_at order. Produce the merge. State its complexity, the buffer it requires in events and in bytes, what happens when one partition is idle, and what you do with an event that arrives after you have already emitted its position. Payloads average 1 KB.

Approach

  1. Merge with a min-heap over the 64 partition heads keyed on (occurred_at, event_id): O(log P) per event and O(n log P) overall. The tie-break on event_id is what makes the output deterministic when two partitions carry the same millisecond, which matters because the feed is paginated and a non-deterministic order reorders pages under the reader.
  2. Emitting the heap head is only correct once every partition has produced everything up to that timestamp, so the emit condition is a watermark: the minimum across partitions of the highest occurred_at seen, less the allowed lateness. Events are held until the watermark passes them, which is what turns individually ordered streams into a jointly ordered one.
  3. Size the buffer from the lateness rather than guessing: 4,000 events per second times 30 seconds is 120,000 buffered events, and at 1 KB each about 120 MB of heap. That number is the real price of the ordering guarantee and belongs in front of whoever asked for it.
  4. Handle the idle partition explicitly, because it fails the feed rather than corrupting it: a partition with no traffic never advances its own maximum, so the watermark freezes and output stops entirely. Either every partition emits a periodic idle marker carrying the broker's current time, or the watermark falls back to wall clock for a partition silent beyond a threshold.

Follow-up

  • The lateness budget is raised to five minutes. What is the new buffer, and what besides memory changes?
  • The consumer restarts. Where does it resume from, and what does the feed look like for the first 30 seconds?

Identify the heaviest tenants in a five-minute window under memory pressure

Medium · top-k · heavy hitters · streaming

The edge service handles about 3,000 requests per second across roughly 50,000 tenants, peaking near 9,000. Expose the 50 heaviest tenants by request count over the trailing five minutes so limits can be tightened before one tenant's backfill starves the fleet. You may not retain five minutes of raw records. Give the exact solution and its memory, then the bounded-memory approximation with its error stated as a formula, and say which you would ship and at what tenant cardinality that choice changes.

Approach

  1. Do the exact version first, because it is affordable at this cardinality: a ring of 300 one-second counters per tenant, advanced lazily, is 1,200 bytes of counters per tenant and roughly 60 to 90 MB for 50,000 tenants with overhead. Carry a running total and subtract the bucket you overwrite so a window read is O(1) rather than 300 adds.
  2. Extract the top 50 with a size-k min-heap over the tenant sums: O(d log k) for d tenants, against O(d log d) to sort them all. Maintaining the heap continuously instead of on query requires a tenant-to-heap-index map, because incrementing a count already inside the heap means sifting from a known position, and without that map you rebuild the heap on every request.
  3. State the approximation precisely rather than gesturing at sketches. Misra-Gries with m counters retains every item whose true count exceeds N/(m+1), and each retained count underestimates the truth by at most N/(m+1). With m = 1,000 and N = 900,000 requests in the window the error is roughly 900 requests, which is fine for spotting a tenant sending 50,000 and useless for ranking two tenants 200 apart.
  4. Say what breaks when the window slides: Misra-Gries and Space-Saving are insert-only and cannot be decremented as records age out. The workable construction is one summary per sub-window, say ten seconds, with 30 summaries merged at query time, and the merged error is the sum of the per-summary errors, so the bound degrades linearly in the number of sub-windows.

Follow-up

  • The heaviest tenant is heavy because of one export job rather than user traffic. Should the limiter treat those as the same tenant?
  • Two tenants sit tied at the boundary of the top 50. Does your answer flap, and does the flapping matter?

Archive a resource graph without breaking live references or recursing

Medium · graph traversal · topological order · tenant isolation

Resources reference other resources within a tenant; for the largest tenant the reference table holds up to 2,000,000 nodes and 8,000,000 edges. Archiving a resource must archive everything reachable from it that nothing outside the set still references, refuse when a live external referrer exists, and terminate when references form cycles, which they legitimately do. Produce the archive order and the refusal list, targeting O(V+E). Say what stops the traversal crossing a tenant boundary, and why recursion is the wrong control structure at this size.

Approach

  1. Load the subgraph with the tenant predicate on both endpoints of the edge, not only on the side you started from. Scoping the left table alone is the classic cross-tenant leak: one mis-entered edge then pulls another tenant's resources into the traversal and, worse, into the archive.
  2. Traverse iteratively with an explicit stack. A 2,000,000-node graph can hold a chain deep enough to exhaust a native stack in the low tens of thousands of frames, and that failure is a process crash rather than an error you can return.
  3. Treat cycles as data rather than corruption: compute strongly connected components with Tarjan in O(V+E) using its own explicit stack, then condense. The condensation is a DAG, so a topological order over it gives the archive order, and every member of a component archives in one transaction because no order within a cycle is valid.
  4. Decide refusals with reverse edges. A candidate is archivable only if every in-edge originates inside the candidate set, so build the transpose or count in-degrees restricted to the visited set, and emit each blocked resource with the id of the external referrer, which is the only part of the answer an operator can act on.

Follow-up

  • The graph is read in one query and the archive writes a minute later. What can change in between, and how do you make the write safe?
  • The candidate set is 400,000 resources. Is that one transaction, and if not, what does a half-finished archive look like to a reader?

Denormalise tenant onto revisions and backfill it live

Hard · denormalisation · online migration · backfill

resource_revision (revision_id, resource_id, version, actor_user_id, change_kind, patch, request_id, created_at) has 400M rows and no tenant column; tenant_id lives only on resource. Two reads need it: a tenant-scoped audit feed ordered by created_at DESC, and an offboarding purge. Both join back to resource today. Justify adding tenant_id to resource_revision against those two reads, name the anomaly the copy introduces and the constraint that prevents it, then give the ordered migration for a live table taking 1.2k writes/second — the lock each step takes, how the backfill is batched, and where each step stops being reversible. PostgreSQL 16.

Approach

  1. Justify from the access path rather than from taste. Without the column, the audit feed either scans resource_revision by created_at and discards other tenants' rows, or resolves the tenant's resource_ids first and probes with them — both proportional to the tenant's whole history rather than to one page. With (tenant_id, created_at DESC, revision_id DESC) it is a seek that stops at 50 rows, and the purge becomes a ranged delete instead of a join.
  2. Name the cost exactly: a second copy of a fact can disagree with the first. Make the disagreement unwritable rather than documented — add UNIQUE (resource_id, tenant_id) on resource so it can serve as a foreign-key target, then FOREIGN KEY (resource_id, tenant_id) REFERENCES resource (resource_id, tenant_id) on the revision table. A revision can then only ever carry its parent's tenant.
  3. Step one, expand: ALTER TABLE resource_revision ADD COLUMN tenant_id BIGINT NULL, with no default, so it is a catalogue change and no rewrite. It still needs ACCESS EXCLUSIVE for an instant, and that instant queues behind the longest open transaction on the table while every later query queues behind it — set lock_timeout to 2s and retry rather than wait.
  4. Step two, dual-write: deploy the writer that populates tenant_id on every new revision while reads still use the join. Reversible by redeploying the previous build, because nothing reads the column yet.

Follow-up

  • The backfill is half finished and a rollback is required. What state is the table in, and what does the previous build do with a half-populated column?
  • How do you verify the backfill actually finished, given rows are still being inserted while it runs?

Keep soft-deleted accounts from blocking re-registration

Easy · soft delete · unique constraints · engine differences

app_user holds user_id, tenant_id, email CITEXT, password_hash (NULL for SSO principals), email_verified_at, auth_version, status ('invited','active','suspended','deactivated'), created_at, updated_at, deleted_at. Two live accounts for one address inside a tenant must be impossible, but an address freed by a soft delete must be reusable, and the same tenant may delete and re-register it repeatedly. Write the uniqueness DDL for PostgreSQL 16, then the equivalent for MySQL 8 where partial indexes do not exist, and say what each permits once three deleted rows already hold that address.

Approach

  1. Start from what is actually unique: not (tenant_id, email), but (tenant_id, email) among live rows. PostgreSQL says that directly — CREATE UNIQUE INDEX app_user_live_email ON app_user (tenant_id, email) WHERE deleted_at IS NULL. A full constraint over the same two columns burns the address permanently the first time someone deletes an account.
  2. Keep case-insensitivity in the type or the index, never in the application: CITEXT as given, or UNIQUE (tenant_id, lower(email)) as an expression index where the extension is unavailable. A case-sensitive unique column is exactly how two accounts for one human appear.
  3. For MySQL 8 the predicate has to move inside the key: add a discriminator column that is a constant 0 while the row is live and is set to user_id on delete, with UNIQUE (tenant_id, email, deleted_marker). Live rows share the constant and still collide; deleted rows differ from each other and stop colliding.
  4. State the NULL variant and its dependency: leaving the marker NULL for deleted rows also works, because a unique index treats NULLs as distinct — true in MySQL, and true in PostgreSQL only under the default NULLS DISTINCT, which PostgreSQL 15 lets you reverse. Check the polarity against the three existing deleted rows: constant-on-live is what preserves the collision you want, and reversing it silently admits duplicate live accounts.

Follow-up

  • A deleted account re-registers with the same address the next day. Do the old resource rows follow the new user_id, and how does the API keep the two principals apart?
  • How do you honour an erasure request while resource_revision.actor_user_id still references this table?

How would you approach refactoring a legacy PHP/Laravel codebase without disrupting existing business-critical

Medium · Technical & Modernization

How would you approach refactoring a legacy PHP/Laravel codebase without disrupting existing business-critical features?

Approach

  1. Pin current behaviour before changing anything: write characterisation tests around the business-critical flows (Laravel HTTP feature tests asserting status, JSON shape and database state) so a refactor that changes output fails a test instead of reaching a customer.
  2. Pick targets by risk and return: rank files by change frequency from git history against bug count, and refactor the hotspots first rather than the oldest-looking code.
  3. Use the strangler pattern inside the app: extract logic from fat controllers into service classes, put an interface in front of each and bind it in the service container, so old and new implementations can be swapped per route without a big-bang rewrite.
  4. Raise the floor without blocking delivery: PHPStan/Larastan with a baseline file (existing errors recorded, new code held to a stricter level) and Rector for mechanical upgrades such as PHP 8.x syntax, each landing as its own small, reviewable PR.
  5. Release behind feature flags (for example Laravel Pennant) so a new path can be enabled for a subset of users and switched off without a deploy, and compare error rates and latency before and after each step.

Follow-up

  • How do you test code that leans on facades, static calls or global state?
  • How would you take the application across several Laravel major versions without freezing feature work?
  • Which part of a legacy codebase would you deliberately leave alone, and why?

How do you ensure high performance and scalability in a REST API architecture?

Medium · Technical & Modernization

Approach

  1. Measure before tuning: per-endpoint p50/p95/p99 latency, throughput and error rate from APM plus the slow query log, so the fix targets the actual bottleneck rather than a guess.
  2. Fix the database path first: remove N+1 queries with eager loading (with() in Eloquent, Model::preventLazyLoading() in development to surface them), add indexes that match each endpoint's filter plus sort order, select only needed columns, and use cursor (keyset) pagination instead of large OFFSETs, which scan and discard every skipped row.
  3. Cache at the right layer: ETag / If-None-Match returning 304 and Cache-Control for cacheable GETs, a CDN for public responses, and Redis for expensive computed results with explicit invalidation keyed by the records they depend on.
  4. Keep app servers stateless (sessions and cache in Redis or the database, not local disk) so they scale horizontally behind a load balancer; move slow work to queues and return 202 Accepted with a status resource the client can poll.
  5. Protect the system under load: rate limits returning 429 with Retry-After, timeouts and circuit breakers on downstream calls, request size limits, response compression, and on PHP specifically OPcache enabled and PHP-FPM worker counts sized to available memory.

Follow-up

  • One endpoint's p99 doubled while p50 stayed flat: how do you find the cause?
  • How do you invalidate a cached list response when a single record in it changes?
  • What does cursor pagination cost compared with offset pagination, and when would you still pick offset?

What is your strategy for managing database migrations in a large, long-standing application?

Medium · Technical & Modernization

Approach

  1. Use expand/contract for every breaking change: add the new column or table, deploy code that writes both, backfill, switch reads, stop writing the old shape, and drop it in a later release; during a rolling deploy old and new code run together, so every step must work with both versions.
  2. Know what takes locks on your engine: on PostgreSQL 11+ adding a column with a constant default is metadata-only, CREATE INDEX CONCURRENTLY avoids blocking writes (but cannot run inside a transaction), and a short lock_timeout stops DDL from queueing all traffic behind it; on MySQL 8 use ALGORITHM=INSTANT or INPLACE where supported, and gh-ost or pt-online-schema-change for large table rebuilds.
  3. Keep migration files fast and schema-only; run data backfills as separate batched, idempotent, resumable jobs (chunkById in Laravel) that are throttled and can be stopped and restarted from the last processed id.
  4. Prefer rolling forward to down() methods for destructive steps: confirm a tested backup or point-in-time restore exists, time each migration against a production-sized anonymised copy, and run php artisan migrate --force from the deploy pipeline rather than by hand.
  5. Keep the history manageable in a long-lived app: squash old migrations with php artisan schema:dump, and add a CI check that runs all migrations from an empty database and from the current production schema.

Follow-up

  • How do you rename a column on a busy table with no downtime?
  • A backfill fails halfway through millions of rows: what state is the data in, and how do you resume?
  • How do you handle a migration that must run against many customer databases at different schema versions?

How have you integrated AI/LLM tools into your daily development lifecycle to improve code quality or velocity

Medium · AI & Innovation

How have you integrated AI/LLM tools into your daily development lifecycle to improve code quality or velocity?

Approach

  1. Give concrete uses with an example each: generating characterisation tests for an untested legacy class, explaining an unfamiliar module before changing it, boilerplate such as form requests and migrations, a first-pass review of my own PR, and documentation; also say where I do not use it and why.
  2. Treat generated code as untrusted input: it goes through the same tests, static analysis and human review as anything else, in small diffs I can read line by line, with particular checks for SQL built from strings, missing authorisation checks and package names that do not exist on Packagist or npm.
  3. Handle data and security deliberately: no secrets, credentials or customer data in prompts, only tools approved for the organisation with retention and training settings checked, and sensitive paths excluded (Cursor's .cursorignore, Copilot content exclusion on business plans).
  4. For agentic work, give the agent a narrow task with a test as the acceptance check and a repository instructions file, then review the full diff before committing rather than accepting it piecemeal.
  5. Back the claim with evidence: what changed in cycle time, PR size, test coverage on legacy modules or escaped defects, and one case where the tool slowed me down or was wrong.

Follow-up

  • Describe a time an AI suggestion looked right but was wrong. How did you catch it?
  • How would you introduce these tools to a team where some engineers distrust them?
  • How do you stop a less experienced engineer from merging generated code they cannot explain?

Describe how you would design a secure, reliable AI-powered workflow for an enterprise customer.

Medium · AI & Innovation

Approach

  1. Fix the scope with one concrete workflow (for example, extracting structured booking fields from an inbound enquiry and suggesting a reply), and decide which actions the model may only suggest and which require a human to approve before anything is written.
  2. Keep the model behind the backend: the browser never holds a provider key; calls go through a service with timeouts, retries with exponential backoff and jitter on 429 and 5xx, idempotency keys so a retry cannot apply twice, a queue for long-running jobs, and a fallback to the manual path when the provider is down.
  3. Constrain and validate output: request structured output, validate it against a JSON schema, reject or retry on failure, and never execute free text; give tools least privilege with an allowlist of actions.
  4. Enforce security outside the prompt: tenant isolation in retrieval by filtering vector and database queries on tenant id server-side, treat retrieved documents and user text as data that may carry prompt injection, redact personal data before the call, and check the provider's retention and data-region terms against the customer's contract.
  5. Operate it like any production service: a labelled evaluation set run in CI on every prompt or model change, versioned prompts, an audit log of inputs, outputs, model version and approver, and dashboards for latency, cost per request, validation failure rate and human override rate, with per-tenant rate limits and budget caps.

Follow-up

  • A document in one customer's knowledge base tells the model to reveal other customers' data: what stops it?
  • The model provider is unavailable for an hour: what does the user see, and what happens to queued jobs?
  • How would you know that a prompt change made output quality worse before customers noticed?

p99 jumped on one listing filter while p50 stayed flat

Medium · query planning · composite index · percentiles

After a release that added an owner_user_id filter to the resource listing, p99 rose from 90 ms to 1.9 s while p50 stayed at 40 ms. Traffic and row counts are unchanged. resource carries the index (tenant_id, status, updated_at DESC, resource_id DESC). The new query filters tenant_id and owner_user_id, orders by updated_at DESC, resource_id DESC, and takes 20 rows. On PostgreSQL, explain the shape of the regression, prove it from a query plan, and give the index you would add.

Approach

  1. Start from the shape. A flat p50 with a moved p99 means a subset of requests changed cost, not all of them, so the first job is naming the subset. Bucket the endpoint's latency by the tenant's row count; the natural hypothesis is that large tenants are a small share of requests and all of the tail.
  2. Get the plan for the new query on a large tenant with EXPLAIN (ANALYZE, BUFFERS). Expect an index scan over the tenant's range, a filter discarding most of it, then a Sort feeding the Limit, possibly reporting Sort Method: external merge Disk. Read actual rows on the scan node, not estimated.
  3. Explain why the existing index cannot serve it. A composite B-tree is seekable only as a left prefix, and with no equality predicate on status the scan cannot treat updated_at as an ordering, because rows in the tenant's range are ordered by status first. Everything matching must be read and sorted before LIMIT 20 can apply, so a tenant with 400,000 rows pays 400,000 rows to return 20.
  4. Add (tenant_id, owner_user_id, updated_at DESC, resource_id DESC). Equality on the first two columns leaves the index ordered by updated_at within that pair, so the plan becomes an index scan that stops after 20 rows with no Sort node. PostgreSQL can scan a B-tree backwards, so the DESC markers matter only if the two sort columns ever disagree in direction; keeping them explicit documents the order the keyset cursor depends on.

Follow-up

  • The endpoint paginates with OFFSET. What does page 500 cost with your index, and what does the keyset version cost?
  • How would you have caught this before release, given that a 10,000-row seed database produces the same plan shape at an unnoticeable cost?

Suggested practice checklist

The plan follows the six reported questions in order: the modernization story first, then the PHP/Laravel refactor, the API and database questions, the two AI questions, a take-home-style build, and a final mock. Practice-library drills support the migration, API latency and behavioral days.

Day 1 — Confirm the format and choose your modernization story

  • Email the recruiter to ask whether the technical assessment is a take-home or live, whether you will write code in later interviews, and which stages follow the screening
  • List three systems you have modernized or refactored and pick the one where you made the key decisions
  • Outline the chosen story: starting state, hurdles, the sequence of changes, how you protected live behaviour, and the measured result

Deliverable: A recruiter email with your format questions, plus a one-page outline of the modernization story to use for the behavioral question and the cover letter.

Day 2 — Refactor a legacy PHP/Laravel codebase safely

  • Write out your step-by-step approach for the legacy PHP/Laravel refactoring question: characterization tests, seams, one slice at a time, feature flags, rollback
  • Take a small legacy-style PHP function from your own work or a sample and write a characterization test, then extract one dependency behind an interface
  • Note how you would handle framework and library upgrades in order (PHP version, framework version, packages) and what you test after each step

Deliverable: A written refactoring plan, one characterization test with the extracted seam, and an ordered upgrade checklist.

Day 3 — Database migrations and REST API performance

  • Outline the migration strategy answer: expand, backfill in batches, switch reads, contract, with the rollback for each step and the check that proves the data matches
  • Work the practice drill on denormalising tenant onto revisions and backfilling it live, and the one on soft-deleted accounts blocking re-registration
  • Write the REST API performance answer by layer: query and index shape, pagination, caching, payload size, rate limits, background work for slow calls, and the metrics you would watch
  • Work the practice debugging drill on a p99 spike on one listing filter, and say what you would check first

Deliverable: A migration runbook for one large-table change, a layered API performance checklist, and a written diagnosis sequence for the p99 drill.

Day 4 — AI tools in your daily development lifecycle

  • Write down your actual toolchain and the tasks you hand to it: code review, documentation, prototyping, test generation
  • For each task, write the check you apply before trusting the output, such as running the tests, reading the diff or restricting what data you paste in
  • Pick one example where an AI tool improved velocity or quality and one where it was wrong, and write both as short stories with the lesson you took

Deliverable: A one-page AI workflow statement with a task-and-check table and two short stories, one success and one failure.

Day 5 — Design a secure, reliable AI-powered workflow

  • Choose an enterprise scenario (for example summarising or classifying customer records inside a platform) and sketch the components: input validation, a retrieval step if you use RAG, the model call, output validation, storage and audit logging
  • Write the failure plan: model timeouts, malformed output, prompt injection through user content, and what the user sees when the AI step fails
  • Add the data-boundary decisions: what customer data is sent to the model, how tenants are separated, and how you would measure whether the feature delivers commercial value

Deliverable: An architecture diagram with a written failure-mode table and a list of the guardrails you would ship first.

Day 6 — Take-home-style build with a README

  • Build a small PHP/Laravel or TypeScript/React exercise of your choice, with unit tests and one integration test
  • Write a README that states your design decisions, the alternatives you rejected and what you would do next with more time
  • Practise sharing your screen and walking through the code and the README out loud

Deliverable: A small tested project with a README of decisions and trade-offs, plus a recorded or rehearsed walkthrough.

Day 7 — Full mock and behavioral polish

  • Answer all six reported questions out loud in sequence, using your outlines from days 1 to 5
  • Work the practice behavioral drills on shipping under a deadline while bounding the debt, and on unblocking an engineer without taking the keyboard
  • Finalise the cover letter around the modernization story, and write two questions to ask the team about the platform and the AI roadmap

Deliverable: A spoken run of all six answers, notes on where you hesitated, a finished cover letter and two questions for the team.

Modernization and AI stories to prepare

The behavioral material reported for this role centres on one prompt, a time you modernized a complex system, and the surrounding themes of ownership, trade-offs between delivery speed and maintainability, and responsible use of AI. Prepare stories where you made the decision and can say why you chose it over the alternatives.

  1. Tell me about a time you balanced delivery speed against long-term maintainability, and what you chose to leave alone.
  2. Describe a time an AI tool gave you something wrong or unsafe. How did you catch it, and what did you change afterwards?
  3. Tell me about a feature you owned from design through deployment and monitoring. What did you check after release?
  4. Describe a time you worked with a product owner to turn a vague request into a technical specification.

Describe a time you had to modernize a complex system. What were the biggest technical hurdles?

Medium · Technical & Modernization

Approach

  1. Choose a project where you made the key decision, and where the system was a real production system with users depending on it. A story where you carried out someone else's plan, or a greenfield rewrite nobody relied on, gives you nothing to defend. If you can, pick one involving a PHP or other long-lived web application, since that matches the platform described for this role.
  2. Open with two sentences of context: what the system did, why it needed modernizing, and what constraint (live customers, no test suite, a deadline) shaped the approach. Then spend most of your time on the hurdles. Name them specifically: undocumented business rules, tight coupling between modules, missing tests, an outdated framework or library version, or a data migration that could not take downtime.
  3. For each hurdle, say what you did and why you chose it over the alternative. A typical sequence is to write characterization tests that pin current behaviour, introduce a seam around one area, move that area behind the new code, run old and new side by side or behind a feature flag, then cut over and delete the old path. State the reason you avoided a big-bang rewrite.
  4. Give the blast radius and the evidence. Say what could have broken, how you limited exposure (staged rollout, rollback plan, monitoring on the affected paths), and what you measured afterwards, such as error rates, latency, deploy frequency or test coverage on the touched code. Use only figures you can actually stand behind.
  5. The mistake that sinks this answer is a technology list with no obstacles ("we moved to the newer framework version and it went well"). Include one disagreement or trade-off, say how you settled it with evidence, and close with what you would change if you ran the project again.

Follow-up

  • What was the single hardest hurdle, and what would have happened if your first approach to it had failed?
  • How did you decide what to leave in the old system rather than modernize?
  • How did you show that your change, and not something else, improved the outcome?

Ship under a deadline and bound the debt you chose

Medium · pagination · technical debt · tradeoffs

You have four days to ship a tenant-facing listing endpoint. The version you would defend uses keyset pagination over (tenant_id, status, updated_at DESC, resource_id DESC); the version you can finish uses LIMIT/OFFSET with no matching index. Describe a deadline call you actually made of this shape: what you shipped, what you knowingly deferred, how you bounded the damage with a mechanism rather than an intention, and the specific numeric condition that would force the follow-up. Name who you told and where you wrote it down.

Approach

  1. Name the deferred failure precisely instead of calling it slow. OFFSET n makes the database produce and discard n rows, so cost grows with page depth; without an index matching the sort, every matching row is read and sorted before the limit applies; and rows inserted between two page fetches shift across the boundary so items are skipped or repeated with nothing in the response to signal it.
  2. Bound the blast radius with something mechanical rather than a promise: cap maximum page depth, cap page size, restrict the endpoint to one internal caller, or keep it behind a flag. State which failure each cap removes and which it leaves standing.
  3. Attach a number to the trigger and wire it to an alarm: the first tenant crossing N resources, or the endpoint's p99 crossing its share of the 400 ms budget, so the debt announces itself instead of waiting to be remembered.
  4. Write it where the next engineer looks, which is the code and the ticket, not a chat message: what was deferred, why, the cap, and the trigger.

Follow-up

  • At what page depth does the offset version breach your latency budget, given your page size and row counts?
  • What breaks first when you switch to keyset pagination later, and what does a client holding an old page token see?

Unblock an engineer without taking the keyboard

Easy · mentoring · leases · at-least-once

A teammate has spent two days on a job handler that occasionally writes duplicate rows. They are certain the queue is delivering twice by mistake. You suspect a lease expiring under a slow handler, so the job is running concurrently with itself. Describe how you have unblocked someone in this position: what you asked before offering a hypothesis, what you showed them rather than told them, and what you left them owning. Then say what you would do if their theory turned out to be the right one.

Approach

  1. Ask before diagnosing, and ask for things answerable from data they already have: the attempt count on the job rows that produced duplicates, the handler's observed duration against its lease expiry, and whether the duplicate rows share a natural key that a unique constraint could have caught.
  2. Teach the shape rather than the answer. A lease cannot distinguish a dead worker from a slow one, so a handler that outruns its lease is running twice by design, and deploys deliver the other half by killing handlers mid-run on every rollout. Both of their candidate theories produce identical duplicate rows, which is why the evidence has to come from timings rather than from argument.
  3. Hand over a checklist they execute: a natural key on every write the handler performs so the second copy collides rather than appends, the record of intent written before any external effect, a lease heartbeat while running, and the metric that shows it working.
  4. Keep ownership with them deliberately. Pair on the first write, then step back; if you finish it yourself you have closed one ticket and left the same person stuck on the next redelivery.

Follow-up

  • How would you distinguish a genuine double-delivery from a lease expiry using only the data already stored?
  • Their handler calls an external endpoint before recording that it did. What do you tell them to change first?