mll-lab/microplate
mll-lab/microplate is a PHP package for modeling microplates, wells, and well coordinates. It supports defining plate formats, validating positions, and organizing assay layouts for lab automation and sample-tracking workflows.
This package models laboratory microplates: coordinate systems (which wells exist on a plate format) and microplate sets (groupings of plates). Two families of classes carry the domain logic:
CoordinateSystem*Well — one class per supported plate format (e.g. a 96-well, 384-well variant). Responsible for translating human-readable well references ("B7", "A1") into indices/positions and back.MicroplateSet* — one class per supported plate-set size, responsible for enumerating and addressing the individual plates within a set.First steps:
composer require the package, then check for a publishable config (php artisan vendor:publish) — plate formats are usually registered or extended there.src/ and locate the CoordinateSystem*Well and MicroplateSet* classes. These are the two entry points you will touch 90% of the time; everything else is supporting value objects.well → position → well) works for your plate format before building anything on top.As of v6.3.0, every CoordinateSystem*Well exposes POSITIONS_COUNT and every MicroplateSet* exposes PLATE_COUNT. Use these instead of hardcoding 96, 384, or 6 anywhere in your application code.
Resolve, don't compute. Rather than deriving a well index from a row letter and column number yourself, let the coordinate system do it. Your application code should be about which wells to touch, not how a well maps to an index.
$system = new CoordinateSystem96Well();
$position = $system->positionOf('B7'); // per your version's API
Treat the constants as the contract. The new POSITIONS_COUNT / PLATE_COUNT constants are the cleanest way to express intent and to stay safe if you later swap plate formats:
$system = new CoordinateSystem96Well();
$all = range(0, CoordinateSystem96Well::POSITIONS_COUNT - 1); // full-plate sweep
$set = new MicroplateSet6();
foreach (range(0, MicroplateSet6::PLATE_COUNT - 1) as $plateIndex) {
// dispatch per-plate work
}
They are especially useful in three places:
Rule::in(range(0, CoordinateSystem96Well::POSITIONS_COUNT - 1)) beats a magic number scattered across form requests.assertCount(MicroplateSet6::PLATE_COUNT, $plates) breaks loudly if the package changes its definition of a set, which is exactly what you want.Keep plate-format selection in config, not in if statements. Register the formats you support in config, resolve the right class by key, and pass the class string (or a small factory) through your services. Pipelines, jobs, and imports then never mention a concrete class name, and adding a format later is a config change rather than a refactor.
Iterate set × position as a single flat plan. For bulk operations (imports, plate-reader ingestion, robot worklists) build one flat list of (plateIndex, position) tuples up front, then chunk it into queue jobs. This decouples "how many things are there" (constants) from "how much work per job" (a tunable you own).
Persist stable identifiers, not objects. Store the plate index and well position (or the canonical well string) in your schema; hydrate the coordinate system on read. Serialising the value objects into JSON columns makes schema migrations painful the moment a class name or shape changes.
Validation at the boundary. Well references arriving from CSVs or user input should be normalised and validated (" b7 " → "B7") in a form request or an import DTO before they ever reach the domain objects. Keep a Rule object per plate format so controllers and imports share it.
POSITIONS_COUNT and PLATE_COUNT are declared on the concrete classes, so you cannot reach them through a shared interface/abstract type in a generic, format-agnostic service. If you need the count for "whatever format this is", either branch on the resolved class, wrap the object in a small adapter that exposes count(), or pass the count alongside the class from your config registry. Don't assume a static call on a class-string works until you've checked the hierarchy.POSITIONS_COUNT - 1 is your highest valid zero-based position. Off-by-one here shows up as a mystery OutOfRangeException (or a silently dropped last well) only on full-plate runs — test a full-plate sweep, not just a handful of wells.96/384/6 somewhere, this release is the prompt to replace them. It also means the package now publicly commits to those counts, so a future format addition is more likely to appear as a new class than to mutate an existing constant.php artisan config:cache is unaffected — but if you're building an array of supported formats for a Rule::in or a select dropdown, make sure that array is built from the same source of truth as the runtime service, or the UI will offer formats the resolver rejects.ref → position → ref) rather than the internals; orientation errors (row-major vs column-major, and the "A1 top-left vs bottom-left" physical convention) account for most surprises, and they're only obvious on asymmetric test data. Use B7 or H12, never A1, in fixtures.CoordinateSystem*Well to add a custom format is usually possible but easy to get wrong — if the base class derives positions from a declared row/column count, override those declarations rather than the lookup methods, and re-run the full-plate round-trip test over POSITIONS_COUNT positions to prove the subclass is internally consistent.How can I help you explore Laravel packages today?