laravel-watchman maintained by shaarifsabah
Laravel Watchman
Sanctions and watchlist screening for Laravel: a native PHP port of moov-io/watchman (Go), based on upstream commit 7f44b4b6.
The package downloads government sanctions lists, normalizes every name exactly the way Watchman does, and indexes them in Redis (or your SQL database). It then screens people, businesses, vessels and aircraft with Watchman's multi-field matcher. Every hit gets a score from 0 to 1, and you can ask why it scored that way.
You do not need the Go server. If you already run one, set WATCHMAN_DRIVER=http and the same Laravel API talks to it instead.
This is an independent community port. It is not an official Moov product and is not affiliated with or endorsed by Moov Financial. Screening results support, and do not replace, your own compliance review.
Contents
- What you get
- Requirements
- Installation
- Choosing a store (Redis / database / memory)
- Configuration reference
- Loading the lists (refresh)
- Searching from PHP (facade)
- Search parameters reference
- Reading results
- Understanding scores and
minMatch - Debugging a score
- HTTP API
- Artisan commands
- Custom lists: file ingestion
- Adding OpenSanctions / Senzing lists
- Offline / cached list files
- Adding your own list source in code
- Using the Go server instead (http driver)
- Using the engine without Laravel
- Tuning the scorer
- Real-world recipes
- Performance and sizing
- Troubleshooting
- How faithful is the port?
- Testing and development
- License
1. What you get
| Watchman (Go) | This package |
|---|---|
GET /v2/search, GET /v2/listinfo, GET/POST /v2/data/refresh, POST /v2/ingest/{fileType} |
Same routes under /watchman/... (prefix configurable) |
| In-memory index rebuilt every 12h | Redis, database or in-memory index, rebuilt by a scheduled queued job; the new index is switched in atomically |
pkg/search scoring |
Watchman\Search\Similarity (line-by-line port, verified against Go) |
| OFAC SDN, OFAC Non-SDN, US CSL, FinCEN 311, EU, UK, UN, US TEL, OpenSanctions/Senzing | Same readers and mappers |
| CSV / Senzing file ingestion | Watchman::ingest() and POST /v2/ingest/{fileType} |
| YAML config + env vars | config/watchman.php + the same env var names |
Built-in list names:
| Name | List |
|---|---|
us_ofac |
OFAC Specially Designated Nationals (SDN) |
us_non_sdn |
OFAC Consolidated (Non-SDN) lists |
us_csl |
US Consolidated Screening List (Commerce, State, Treasury) |
us_fincen_311 |
FinCEN Section 311 / 9714 special measures |
eu_csl |
EU Consolidated Financial Sanctions List |
uk_csl |
UK Sanctions List (FCDO) |
un_csl |
UN Security Council Consolidated List |
us_tel |
US State Department Terrorist Exclusion List |
| any name | Any Senzing-format dataset (OpenSanctions or your own) |
2. Requirements
- PHP: 8.2 or newer. You need the
intl,mbstring,dom,simplexmlandcurlextensions.igbinaryis recommended, because it makes the index smaller and faster.zipis needed only to read the UK list from an.odsfile.
- Laravel: 12 or 13. (Laravel 10 and 11 no longer receive security fixes, and Composer refuses to install them.)
- For the default store: Redis, with either phpredis (
REDIS_CLIENT=phpredis, the Laravel default) or predis. - Without Redis: any database Laravel supports (MySQL, PostgreSQL, SQLite or SQL Server), used through the
databasestore.
3. Installation
composer require shaarifsabah/laravel-watchman
To work on the package itself from a local checkout instead, use a path repository:
# in your Laravel app
composer config repositories.watchman '{"type":"path","url":"../laravel-watchman","options":{"symlink":true}}'
composer require "shaarifsabah/laravel-watchman:dev-main"
Then
php artisan vendor:publish --tag=watchman-config # creates config/watchman.php
php artisan migrate # watchman_* tables (ingested files; index tables used by the database store)
php artisan watchman:refresh # first download + index build
php artisan watchman:status # check what is loaded
The service provider (Watchman\Laravel\WatchmanServiceProvider) and the Watchman facade are auto-discovered.
If you also want the migration file in your own database/migrations, publish it:
php artisan vendor:publish --tag=watchman-migrations
4. Choosing a store
Set the store with WATCHMAN_STORE:
| Store | When to use it | Notes |
|---|---|---|
redis (default) |
Production. A single index shared by every PHP-FPM, queue or Octane worker; long-running workers pick up each refresh automatically. | Fastest. Needs a Redis that never evicts keys. |
database |
No Redis available. | Uses the watchman_* tables. Searches become indexed SQL lookups. |
memory |
Tests, one-off scripts, long-running workers. | Lives only inside the current PHP process. |
Redis setup
Give Watchman its own connection, so that cache:clear can never wipe the index and LRU eviction can never drop part of it.
// config/database.php → 'redis'
'watchman' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('WATCHMAN_REDIS_DB', '3'),
],
WATCHMAN_STORE=redis
WATCHMAN_REDIS_CONNECTION=watchman
WATCHMAN_REDIS_PREFIX=watchman:
On that Redis server, set:
maxmemory-policy noeviction
php artisan watchman:status prints a warning if the policy allows eviction.
Redis keeps everything in memory. If it restarts without RDB/AOF persistence, the index is gone: run php artisan watchman:refresh again. Ingested files are kept in the database and are re-indexed automatically during every refresh.
Database setup
WATCHMAN_STORE=database
WATCHMAN_DB_CONNECTION=mysql # optional, defaults to your default connection
Run php artisan migrate before the first refresh.
Memory store
WATCHMAN_STORE=memory
Each PHP process must call Watchman::refresh() itself. This suits tests and Octane, not PHP-FPM.
5. Configuration reference
Every key in config/watchman.php, with its environment variable:
Driver and stores
| Key | Env | Default | Meaning |
|---|---|---|---|
driver |
WATCHMAN_DRIVER |
native |
native runs the PHP engine; http calls a Go Watchman server |
http.base_url |
WATCHMAN_URL |
http://localhost:8084 |
Go server URL (http driver only) |
http.timeout |
WATCHMAN_HTTP_TIMEOUT |
10 |
Seconds |
http.headers |
– | [] |
Extra headers, e.g. for an auth proxy |
store |
WATCHMAN_STORE |
redis |
redis, database or memory |
redis.connection |
WATCHMAN_REDIS_CONNECTION |
default |
Name from config/database.php |
redis.prefix |
WATCHMAN_REDIS_PREFIX |
watchman: |
Key prefix |
database.connection |
WATCHMAN_DB_CONNECTION |
default connection | |
database.table_prefix |
– | watchman_ |
Downloads (Watchman's Download section)
| Key | Env | Default |
|---|---|---|
download.included_lists |
WATCHMAN_INCLUDED_LISTS or INCLUDED_LISTS (comma separated) |
us_ofac,us_non_sdn,us_csl,us_fincen_311 |
download.ignored_download_errors |
WATCHMAN_IGNORED_DOWNLOAD_ERRORS or IGNORED_DOWNLOAD_ERRORS |
empty |
download.initial_data_directory |
WATCHMAN_INITIAL_DATA_DIRECTORY or INITIAL_DATA_DIRECTORY |
empty |
download.error_on_empty_list |
WATCHMAN_ERROR_ON_EMPTY_LIST or ERROR_ON_EMPTY_LIST |
false |
download.timeout |
WATCHMAN_DOWNLOAD_TIMEOUT |
120 seconds |
download.temp_directory |
WATCHMAN_TEMP_DIRECTORY |
storage/app/watchman |
download.urls.us_ofac |
OFAC_DOWNLOAD_TEMPLATE |
OFAC sanctions list service (%s = file name) |
download.urls.us_non_sdn |
US_NON_SDN_DOWNLOAD_TEMPLATE |
same service |
download.urls.us_csl |
US_CSL_DOWNLOAD_TEMPLATE / US_CSL_DOWNLOAD_URL |
data.trade.gov |
download.urls.us_fincen_311 |
FINCEN_311_DOWNLOAD_URL |
fincen.gov page |
download.urls.eu_csl |
EU_CSL_DOWNLOAD_URL, or EU_CSL_TOKEN to change only the token |
EU webgate |
download.urls.uk_csl |
UK_SANCTIONS_LIST_URL |
FCDO CSV |
download.urls.un_csl |
UN_CONSOLIDATED_LIST_URL |
scsanctions.un.org XML |
download.urls.us_tel |
US_TEL_URL |
OpenSanctions |
download.open_sanctions.api_key |
OPENSANCTIONS_API_KEY |
empty (not needed for public bulk files) |
download.open_sanctions.lists |
– | none (5 US datasets are listed, commented out; see §15) |
download.senzing |
– | [] (any other Senzing files) |
Refresh, search, scoring and routes
| Key | Env | Default |
|---|---|---|
refresh.schedule |
WATCHMAN_SCHEDULE_REFRESH |
true |
refresh.cron |
WATCHMAN_REFRESH_CRON |
17 */12 * * * (every 12h) |
refresh.queue_connection |
WATCHMAN_QUEUE_CONNECTION |
app default |
refresh.queue |
WATCHMAN_QUEUE |
app default |
refresh.memory_limit |
WATCHMAN_REFRESH_MEMORY |
2G |
refresh.lock_seconds |
– | 7200 |
refresh.cache_store |
WATCHMAN_CACHE_STORE |
app default (used for the lock and status) |
search.default_limit / search.max_limit |
– | 10 / 100 (100 is the most Watchman returns) |
search.chunk_size |
– | 500 entities loaded per batch while scoring |
scoring.* |
same names as Watchman (see §20) | Watchman defaults |
tfidf.enabled |
TFIDF_ENABLED |
false |
routes.enabled |
WATCHMAN_ROUTES |
false |
routes.prefix |
WATCHMAN_ROUTE_PREFIX |
watchman |
routes.middleware |
– | ['api', 'throttle:60,1']; add your auth middleware |
routes.admin_enabled |
WATCHMAN_ADMIN_ROUTES |
false |
routes.admin_middleware |
– | ['api']; admin routes register only once this holds an auth middleware |
ingest.max_body_bytes |
INGEST_MAX_BODY_BYTES |
32 MiB |
ingest.max_decompressed_bytes |
INGEST_MAX_DECOMPRESSED_BYTES |
256 MiB (largest size a .gz upload may expand to) |
index.serializer |
WATCHMAN_SERIALIZER |
auto (igbinary when installed); php decodes with a class allow-list, for stores shared with less trusted clients. Run a refresh after changing it. |
index.retire_after_seconds |
WATCHMAN_RETIRE_AFTER |
300 (how long the previous index is kept after a refresh, for searches still running on it) |
ingest.files |
– | [] (see §14) |
A typical .env:
WATCHMAN_DRIVER=native
WATCHMAN_STORE=redis
WATCHMAN_REDIS_CONNECTION=watchman
WATCHMAN_INCLUDED_LISTS=us_ofac,us_non_sdn,us_csl,us_fincen_311
WATCHMAN_QUEUE=watchman
WATCHMAN_ADMIN_ROUTES=false
6. Loading the lists (refresh)
A refresh downloads every configured list, parses it, normalizes every name and writes a new index generation. When everything succeeds, the new index replaces the old one atomically and the old one is deleted. While a refresh runs, searches keep using the previous index.
php artisan watchman:refresh # run now, in this process
php artisan watchman:refresh --queue # dispatch the RefreshLists job to your queue
use Watchman\Laravel\Facades\Watchman;
$stats = Watchman::refresh(); // blocking; returns lists, listHashes, startedAt, endedAt, version, warnings
Watchman::refreshStatus(); // ['state' => 'running'|'succeeded'|'failed'|'queued'|'idle', 'startedAt' => …, 'lastError' => …]
use Watchman\Laravel\Jobs\RefreshLists;
RefreshLists::dispatch('manual')->onQueue('watchman');
Automatic refresh every 12 hours
The package registers a scheduled job (watchman:refresh, cron 17 */12 * * *). Two things must be running:
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1 # cron
php artisan queue:work --queue=watchman,default --timeout=7200 # worker
Notes:
- Long-running job: a refresh can take minutes when large OpenSanctions lists are enabled, so give the queue worker a long
--timeout. - Database queues: if you use the database or Redis queue driver, also raise
retry_afterabove the timeout inconfig/queue.php. - One refresh at a time: the cache lock
watchman:refresh:lockmakes sure only one refresh runs. Use a cache store that supports locks (redis, database, file or memcached). - Turning it off: set
WATCHMAN_SCHEDULE_REFRESH=falseto disable the schedule and trigger refreshes yourself.
What happens when a list fails
- If a list is named in
ignored_download_errors, a download or parse failure only adds a warning. The other lists are still loaded. - Any other failure aborts the refresh, the previous index stays active, and
refreshStatus()reportsfailedwith the error. - An unknown name in
included_liststhrowsunknown lists: …, as in Watchman.
7. Searching from PHP
use Watchman\Laravel\Facades\Watchman;
$result = Watchman::search([
'type' => 'person',
'name' => 'Dmitry Khoroshev',
'birthDate' => '1993-04-17',
'minMatch' => 0.80,
'limit' => 5,
]);
if ($result->isEmpty()) {
// no hit at or above 0.80
}
$best = $result->first(); // ?SearchedEntity
$best->match; // 0.867
$best->source; // 'us_ofac'
$best->sourceId; // '48603'
$best->name; // 'Dmitry Yuryevich KHOROSHEV'
Shortcut methods:
Watchman::searchPerson('Nicolas Maduro', ['birthDate' => '1962-11-23', 'minMatch' => 0.8]);
Watchman::searchBusiness('Banco Nacional de Cuba', ['address' => 'Havana, Cuba']);
Watchman::searchVessel('Pioneer', ['imoNumber' => '9187629']);
// true when the best hit is >= the cutoff (default 0.80)
Watchman::isMatch(['type' => 'person', 'name' => 'Dmitry Khoroshev', 'birthDate' => '1993-04-17']);
Watchman::isMatch(['type' => 'business', 'name' => 'Aerocaribbean Airlines'], 0.75);
Dependency injection works too:
use Watchman\Laravel\WatchmanManager; // or Watchman\Contracts\WatchmanClient
public function __construct(private WatchmanManager $watchman) {}
$this->watchman->search(['name' => 'Jane Doe', 'type' => 'person']);
Advanced: build the query yourself
use Watchman\Laravel\Clients\NativeClient;
use Watchman\Search\{Entity, Person, GovernmentID, SearchOptions};
$query = new Entity('Dmitry Khoroshev', 'person', 'api-request');
$query->person = new Person('Dmitry Khoroshev', birthDate: new DateTimeImmutable('1993-04-17', new DateTimeZone('UTC')));
$query->person->governmentIDs = [new GovernmentID('passport', 'Russia', '2018278055')];
$response = app(NativeClient::class)->searchEntity(
$query->normalize(), // always normalize a query before searching
new SearchOptions(limit: 5, minMatch: 0.8, debug: true),
);
8. Search parameters reference
These are the same names as Watchman's GET /v2/search. Pass them as an array to Watchman::search(), or as query string parameters over HTTP. Repeatable fields accept an array in PHP, or the same key repeated in the URL (?altNames=A&altNames=B, or altNames[]=A).
General
| Parameter | Example | Notes |
|---|---|---|
name |
Dmitry Khoroshev |
Name to screen (any order, any case, accents OK) |
type |
person, business, organization, vessel, aircraft |
Always send it when you know it. Person, business and organization can still match each other; vessels and aircraft only match their own type. |
source |
us_ofac |
Search one list only. Leave empty to search all downloaded lists. |
sourceID |
48603 |
Returns that exact list record with match 1.0 |
altNames |
['Dima Khoroshev'] |
Other names of the query subject (repeatable) |
Person
| Parameter | Example |
|---|---|
gender |
male, female, m, f |
birthDate |
1993-04-17, 1993-04 or 1993 |
deathDate |
2020-01-31 |
titles |
['CEO'] (repeatable) |
Business and organization
| Parameter | Example |
|---|---|
created |
2001-05-01 |
dissolved |
2015 |
Government IDs (person, business, organization)
Use gov_<type>=<country>:<identifier>, for example:
'gov_passport' => 'Russia:2018278055',
'gov_tax-id' => 'Russia:366110340670',
'gov_national-id' => 'IR:0012345678',
'gov_business-registration' => 'CN:91110000100000000X',
The supported types are passport, drivers-license, national-id, tax-id, ssn, cedula, curp, cuit, electoral, business-registration, commercial-registry, birth-certificate, refugee-id, diplomatic-passport, personal-id, citizenship and nationality.
Use the same country spelling the list uses. OFAC stores full names such as "Russia", so Russia:… matches exactly. A different spelling (RU:…) counts as a matching number with a country mismatch, which gives a lower score.
Vessel
| Parameter | Example |
|---|---|
imoNumber |
9187629 |
mmsi |
273212870 |
callSign |
UBGT3 |
flag |
Russia |
vesselType |
cargo |
built |
2001 |
model |
|
owner |
|
tonnage |
4500 |
grossRegisteredTonnage |
12000 |
Aircraft
| Parameter | Example |
|---|---|
serialNumber |
1003 |
icaoCode |
B744 |
model |
Boeing 747 |
flag |
|
built |
|
aircraftType |
cargo |
Contact, address and crypto (any type)
| Parameter | Example |
|---|---|
email (also emailAddress, emailAddresses) |
info@tidewaterco.com |
phone (also phoneNumber, phoneNumbers) |
+98 21 8855 3321 |
fax (also faxNumber, faxNumbers) |
|
website (also websites) |
www.tidewaterco.com |
domain (also domains) |
tidewaterco.com |
address (also addresses) |
123 Main St, Las Vegas NV 89101 (free text) |
cryptoAddress (also cryptoAddresses) |
XBT:bc1qvhnfknw852ephxyc5hm4q520zmvf9maphetc9z |
Search options
| Parameter | Default | Notes |
|---|---|---|
limit |
10 |
Max 100 |
minMatch |
0 |
Only return hits with a score at least this high (0.80 is the usual screening cutoff) |
debug |
off | true attaches score details to every hit; a number such as 0.80 attaches them only to hits scoring at least that much |
algorithm |
jaro-winkler |
Also soundex, double-metaphone, beider-morse, editex, nsim, nsim-3, soft-bidist, soft-bisim |
requestID |
Free text, for your logs | |
format |
watchman |
HTTP only: senzing (JSON array) or senzing/jsonl |
Unknown parameter names are rejected, exactly like Watchman. Over HTTP you get a 400; from PHP, Watchman::search() throws InvalidArgumentException (extra/unused query parameters in request: birthdate). A misspelled field therefore never silently weakens a screening.
9. Reading results
Watchman::search() returns a Watchman\Search\SearchResponse:
$response->query; // the normalized query Entity
$response->entities; // list<SearchedEntity>, best first
$response->first(); // ?SearchedEntity
$response->isEmpty();
$response->toArray(); // Watchman JSON: ['query' => …, 'entities' => […]]
Each hit is a Watchman\Search\SearchedEntity, which extends Entity:
| Property | Type | Meaning |
|---|---|---|
match |
float | Score 0–1 |
name |
string | Primary name on the list |
type |
string | person, business, organization, vessel or aircraft |
source |
string | List name (us_ofac, us_csl, …) |
sourceId |
string | ID in that list (OFAC ent_num, CSL id, …) |
person |
?Person | name, altNames, gender, birthDate, placeOfBirth, deathDate, titles, governmentIDs |
business / organization |
?Business | name, altNames, created, dissolved, governmentIDs |
vessel |
?Vessel | imoNumber, mmsi, callSign, flag, type, built, tonnage, owner, … |
aircraft |
?Aircraft | serialNumber, icaoCode, model, flag, built, … |
contact |
ContactInfo | emailAddresses, phoneNumbers, faxNumbers, websites |
addresses |
list<Address> | line1, line2, city, postalCode, state, country |
cryptoAddresses |
list | currency, address |
affiliations |
list | entityName, type, details |
sanctionsInfo |
?SanctionsInfo | programs, secondary, description |
historicalInfo |
list | type (e.g. "Former Name"), value |
sourceData |
array | The original list record (OFAC row, CSL row, Senzing record…) |
debug |
string | Base64 text explanation (only with debug) |
details |
?SimilarityScore | The 9 score pieces (only with debug) |
Dates are DateTimeImmutable in UTC. toArray() / json_encode() produce the same JSON field names as Watchman (entityType, sourceList, sourceID, …), so existing Watchman clients can read the output.
Example: save a screening record for your audit trail. Watchman never stores searches, so you must.
$res = Watchman::searchPerson($customer->full_name, ['birthDate' => $customer->dob, 'minMatch' => 0.80]);
$info = Watchman::listInfo();
ScreeningLog::create([
'customer_id' => $customer->id,
'cutoff' => 0.80,
'list_hashes' => $info['listHashes'], // which list versions were checked
'hits' => array_map(fn ($h) => [
'list' => $h->source, 'id' => $h->sourceId, 'name' => $h->name, 'match' => $h->match,
], $res->entities),
]);
10. Understanding scores and minMatch
- The scale: every hit is scored 0–1. Watchman's documentation uses 0.80 as a production screening cutoff (precision ≈ 0.99 on a public labeled set), and about 0.59 for high-recall review queues.
- Name alone is capped: a name-only match is limited by design. For example, "Dmitry Khoroshev" against the OFAC record scores 0.767, below 0.80. Adding fields your KYC process already collects raises a true match: with
birthDate=1993-04-17it scores 0.867. - Unique IDs score 1.0: a matching passport, IMO number, MMSI, aircraft serial or crypto address (with matching country/currency) gives 1.0 immediately.
- Conflicting IDs lower the score: if both sides have an ID of the same type and the numbers differ, the score is multiplied by 0.70.
- Numerals in names: names containing different numbers ("Star 1" vs "Star 2") are also multiplied by 0.70.
- What goes into the score:
- Exact identifiers, crypto addresses, government IDs and contact details (weight 50).
- Name and titles (35).
- Addresses (25).
- Dates and supporting info (15).
- Coverage penalties are then applied.
The debug output shows each of these steps.
11. Debugging a score
$res = Watchman::search(['type' => 'person', 'name' => 'Dmitry Khoroshev', 'debug' => true, 'limit' => 1]);
$hit = $res->first();
echo base64_decode($hit->debug); // human-readable explanation
foreach ($hit->details->pieces as $piece) { // 9 ScorePiece objects
echo "{$piece->pieceType}: score={$piece->score} weight={$piece->weight} compared={$piece->fieldsCompared}\n";
}
php artisan watchman:search "Dmitry Khoroshev" --type=person --debug
Or compare one record directly:
use Watchman\Search\{Entity, Similarity, DebugLog};
$log = new DebugLog();
$details = Similarity::detailed($queryEntity->normalize(), $listEntity, null, $log);
echo $log->contents();
12. HTTP API
The routes are registered under the prefix watchman (set WATCHMAN_ROUTE_PREFIX; use an empty value to get Watchman's exact paths).
The routes are off by default. Enable them with WATCHMAN_ROUTES=true once you have added authentication middleware (see the security note below).
| Method | Path | Needs | Purpose |
|---|---|---|---|
| GET | /watchman/v2/search |
WATCHMAN_ROUTES=true |
Search (§8) |
| GET | /watchman/v2/listinfo |
WATCHMAN_ROUTES=true |
Loaded lists, counts, hashes, last refresh |
| GET | /watchman/v2/data/refresh |
WATCHMAN_ROUTES=true |
Refresh status |
| GET | /watchman/ping |
WATCHMAN_ROUTES=true |
Health check (PONG) |
| POST | /watchman/v2/data/refresh |
WATCHMAN_ADMIN_ROUTES=true + auth in admin_middleware |
Queue a refresh (202, or 409 if already running) |
| POST | /watchman/v2/ingest/{fileType} |
WATCHMAN_ADMIN_ROUTES=true + auth in admin_middleware |
Upload a custom list file |
Security: like Watchman, these endpoints have no authentication of their own. Before enabling them, set
routes.middleware(e.g.['api', 'auth:sanctum', 'throttle:60,1']) and, for the admin routes,routes.admin_middleware(e.g.['api', 'auth:sanctum', 'can:manage-watchman']). Admin routes are registered only whenadmin_middlewareincludes an auth middleware, recognised by name:auth…(auth,auth:sanctum,auth.basic),can:…, or a class whose name containsAuth(such asAuthenticate). Server-side failures return a generic 500 and are logged; refresh errors are not shown over HTTP.
Examples:
curl "http://localhost/watchman/v2/search?type=person&name=Dmitry+Khoroshev&birthDate=1993-04-17&minMatch=0.80&limit=1"
{
"query": { "name": "Dmitry Khoroshev", "entityType": "person", "sourceList": "api-request", "...": "..." },
"entities": [
{
"name": "Dmitry Yuryevich KHOROSHEV",
"entityType": "person",
"sourceList": "us_ofac",
"sourceID": "48603",
"person": { "name": "Dmitry Yuryevich KHOROSHEV", "birthDate": "1993-04-17T00:00:00Z", "...": "..." },
"sourceData": { "entityID": "48603", "sdnName": "KHOROSHEV, Dmitry Yuryevich", "...": "..." },
"match": 0.867
}
]
}
# repeated parameters
curl "http://localhost/watchman/v2/search?type=person&name=Nicolas+Maduro&altNames=Nicolas+Maduro+Moros&altNames=Maduro"
# score details
curl "http://localhost/watchman/v2/search?type=person&name=Dmitry+Khoroshev&debug=true&limit=1"
# Senzing output (one JSON object per line)
curl "http://localhost/watchman/v2/search?type=person&name=Dmitry+Khoroshev&format=senzing/jsonl"
curl -H "Accept: senzing" "http://localhost/watchman/v2/search?name=..."
# lists and refresh
curl http://localhost/watchman/v2/listinfo
curl http://localhost/watchman/v2/data/refresh
curl -X POST http://localhost/watchman/v2/data/refresh # admin routes only
# ingest a CSV (admin routes only)
curl -X POST --data-binary @fincen-person.csv -H "Content-Type: text/csv" \
http://localhost/watchman/v2/ingest/fincen-person
Errors are JSON with status 400, e.g. {"error":"unknown algorithm (supported: …)"}. An oversized ingest body returns 413.
13. Artisan commands
| Command | What it does |
|---|---|
watchman:refresh |
Download all lists and rebuild the index now. Prints a table of lists, counts and hashes. |
watchman:refresh --queue |
Dispatch the refresh job instead of running it. |
watchman:refresh --trigger=deploy |
Name recorded in the refresh status. |
watchman:search "NAME" |
Screen a name from the terminal. |
watchman:status |
Driver, store, loaded lists, last refresh, refresh status, Redis eviction warning. |
watchman:gc |
Delete index generations left behind by crashed or killed refreshes. |
watchman:search options:
php artisan watchman:search "Dmitry Khoroshev" \
--type=person \
--limit=5 \
--min-match=0.5 \
--param=birthDate=1993-04-17 \
--param=gov_passport=Russia:2018278055 \
--param=altNames=Dima \
--debug # print the score explanation
# --json # print the raw JSON response instead of a table
14. Custom lists: file ingestion
Use this for your own internal blocklists, FinCEN 314(a) files, or any CSV. Each file type gets a column mapping, which uses the same structure as Watchman's YAML.
// config/watchman.php
'ingest' => [
'max_body_bytes' => 32 * 1024 * 1024,
'files' => [
'fincen-person' => [
'format' => 'csv', // csv | senzing | senzing-json | senzing-jsonl
'mapping' => [
'name' => ['merge' => ['first_name', 'suffix', 'middle_name', 'last_name']],
'sourceID' => ['column' => 'tracking_number'],
'type' => ['default' => 'person'],
'person' => [
'altNames' => ['merge' => ['alias_first_name', 'alias_suffix', 'alias_middle_name', 'alias_last_name']],
'birthDate' => ['column' => 'dob'], // 2006-01-02, 1/2/2006 or 01/02/2006
'governmentIDs' => ['identifier' => ['column' => 'number']],
],
'contact' => ['phoneNumbers' => ['columns' => 'phone']],
'addresses' => [
'line1' => ['columns' => 'street'], 'city' => ['columns' => 'city'],
'state' => ['columns' => 'state'], 'postalCode' => ['columns' => 'zip'],
'country' => ['columns' => 'country'],
],
],
],
'fincen-business' => [
'format' => 'csv',
'mapping' => [
'name' => ['column' => 'business_name'],
'sourceID' => ['column' => 'tracking_number'],
'type' => ['default' => 'business'],
'business' => [
'altNames' => ['columns' => 'dba_name'],
'created' => ['column' => 'incorporated'],
'governmentIDs' => ['identifier' => ['column' => 'number']],
],
],
],
'internal-blocklist' => ['format' => 'senzing'], // Senzing JSON or JSON Lines
],
],
Mapping rules:
columntakes one column's value.mergejoins several columns with spaces, skipping empty ones.- For list fields,
columnstakes one column andmergeadds one combined value. - Rows with the same
sourceIDare merged into one entity, as in Watchman.
Upload and search (.gz files are accepted too):
Watchman::ingest('fincen-person', file_get_contents(storage_path('fincen-person.csv')));
// Ingested lists are searched when you name them as the source, as in Watchman:
Watchman::search(['type' => 'person', 'name' => 'John Doe', 'source' => 'fincen-person']);
Uploading again replaces that file type's data. Rows are stored in watchman_ingested_entities and re-indexed after every refresh, so they survive Redis restarts.
15. Adding OpenSanctions / Senzing lists
Any dataset published in Senzing format can be added. Its source_list name is added to the loaded lists automatically, and hits are tagged with that name.
None are enabled by default. The published config lists five US datasets, commented out. Before enabling OpenSanctions data, check its license terms: it is free for non-commercial use, and commercial use needs a license from OpenSanctions. Large datasets such as us_sam_exclusions (100k+ records) also make refreshes and searches noticeably slower.
'open_sanctions' => [
'api_key' => env('OPENSANCTIONS_API_KEY'), // only needed for non-public endpoints
'lists' => [
['source_list' => 'us_sam_exclusions', 'location' => 'https://data.opensanctions.org/datasets/latest/us_sam_exclusions/senzing.json'],
['source_list' => 'gb_hmt_sanctions', 'location' => 'https://data.opensanctions.org/datasets/latest/gb_hmt_sanctions/senzing.json'],
],
],
// your own Senzing files (local file:// paths or URLs)
'senzing' => [
['source_list' => 'internal_pep', 'location' => 'file:///var/data/internal_pep.jsonl'],
],
Add the names to ignored_download_errors if a failed download should only produce a warning:
WATCHMAN_IGNORED_DOWNLOAD_ERRORS=us_sam_exclusions,gb_hmt_sanctions
Search a single dataset with 'source' => 'us_sam_exclusions'.
16. Offline / cached list files
Point WATCHMAN_INITIAL_DATA_DIRECTORY at a folder. Any file found there (name compared case-insensitively) is used instead of downloading. This is how you run air-gapped, or pin list versions:
| List | File name(s) |
|---|---|
us_ofac |
sdn.csv, add.csv, alt.csv, sdn_comments.csv |
us_non_sdn |
CONS_PRIM.CSV, CONS_ADD.CSV, CONS_ALT.CSV, CONS_COMMENTS.CSV |
us_csl |
consolidated.csv |
us_fincen_311 |
fincen_311.html |
eu_csl |
eu_csl.csv |
uk_csl |
UK_Sanctions_List.csv (or UK_Sanctions_List.ods) |
un_csl |
un_consolidated.xml |
us_tel |
us_tel.json |
| Senzing / OpenSanctions | a file named exactly like its source_list, e.g. us_sam_exclusions |
You can also send every download through a mirror or cache (such as moov-io/watchman-cache) with the URL variables in §5, e.g. OFAC_DOWNLOAD_TEMPLATE=https://cache.internal/ofac/%s. file:///path locations work as well.
17. Adding your own list source in code
Implement Watchman\Download\ListSource and register it in a service provider:
use Watchman\Download\{Downloader, ListRefresher, ListResult, ListSource};
use Watchman\Search\{Entity, Business};
final class MyVendorList implements ListSource
{
public function name(): string { return 'vendor_blocklist'; }
public function load(Downloader $downloader, string $initialDir): ListResult
{
$files = $downloader->getFiles($initialDir, ['vendors.json' => 'https://example.com/vendors.json']);
$path = $files['vendors.json'] ?? throw new RuntimeException('vendors.json not available');
$entities = (function () use ($path) {
foreach (json_decode(file_get_contents($path), true) as $row) {
$e = new Entity($row['name'], 'business', 'vendor_blocklist', (string) $row['id']);
$e->business = new Business($row['name'], $row['aliases'] ?? []);
yield $e->normalize(); // always normalize
}
})();
return new ListResult('vendor_blocklist', $entities, hash_file('sha256', $path));
}
}
// AppServiceProvider::register()
$this->app->extend(ListRefresher::class, function (ListRefresher $r) {
$r->extend('vendor_blocklist', fn () => new MyVendorList());
return $r;
});
Then add vendor_blocklist to WATCHMAN_INCLUDED_LISTS.
18. Using the Go server instead (http driver)
If you already run Watchman (docker run -p 8084:8084 moov/watchman):
WATCHMAN_DRIVER=http
WATCHMAN_URL=http://watchman.internal:8084
Watchman::search(), listInfo(), refresh(), refreshStatus() and ingest() are then sent to the Go server and return the same PHP objects. The package routes forward to it as well. Nothing is indexed locally, and the scheduled refresh is skipped because the Go server refreshes itself.
You can also use both drivers side by side:
Watchman::driver('native')->search([...]);
Watchman::driver('http')->search([...]);
19. Using the engine without Laravel
Everything under Watchman\ except Watchman\Laravel is plain PHP:
require 'vendor/autoload.php';
use Watchman\Download\{Downloader, ListRefresher};
use Watchman\Index\MemoryStore;
use Watchman\Search\{QueryParser, SearchService};
$store = new MemoryStore(); // or Watchman\Index\Redis\RedisStore / Database\DatabaseStore
$refresher = new ListRefresher($store, new Downloader(), [
'included_lists' => ['us_ofac'],
'initial_data_directory' => '/data/lists',
]);
$refresher->refresh();
$parser = QueryParser::fromQueryString('type=person&name=Dmitry+Khoroshev&minMatch=0.5');
$hits = (new SearchService($store))->search($parser->entity(), $parser->options());
foreach ($hits as $hit) {
printf("%.3f %s %s\n", $hit->match, $hit->sourceId, $hit->name);
}
The individual building blocks can also be used on their own:
use Watchman\Search\{Entity, Similarity};
use Watchman\Prepare\{Normalize, Stopwords};
use Watchman\StringScore\JaroWinkler;
use Watchman\Norm\{Country, Phone, Domain};
use Watchman\Address\UsAddress;
Similarity::score($query->normalize(), $candidate->normalize()); // 0..1
Normalize::lowerAndRemovePunctuation('Nicolás MADURO'); // "nicolas maduro"
Stopwords::remove('the bank of new york'); // "bank new york"
JaroWinkler::bestPairCombination(['john', 'smith'], ['jon', 'smyth']);
Country::normalize('IR'); // "Iran"
Phone::normalize('+1 (702) 555-1234'); // "17025551234"
Domain::keys('https://mail.example.co.uk/x'); // ["mail.example.co.uk", "example.co.uk"]
UsAddress::parse('123 Main St Apt 4B, Springfield, IL 62704'); // Address object
20. Tuning the scorer
The defaults are Watchman's. Change them only if you have measured why. Each value can be set as an environment variable or in config('watchman.scoring'):
| Variable | Default | Effect |
|---|---|---|
JARO_WINKLER_BOOST_THRESHOLD |
0.7 | Jaro score above which the prefix boost applies |
JARO_WINKLER_PREFIX_SIZE |
4 | Prefix length for the boost |
LENGTH_DIFFERENCE_CUTOFF_FACTOR |
0.9 | Token length ratio below which a penalty applies |
LENGTH_DIFFERENCE_PENALTY_WEIGHT |
0.3 | Strength of that penalty |
DIFFERENT_LETTER_PENALTY_WEIGHT |
0.9 | Multiplier when tokens start with different sounds |
UNMATCHED_INDEX_TOKEN_WEIGHT |
0.15 | Penalty for list-name words the query didn't match |
EXACT_MATCH_FAVORITISM |
0 | Bonus for exact words (legacy scorer) |
ADJACENT_SIMILARITY_POSITIONS |
3 | Legacy scorer window |
DISABLE_PHONETIC_FILTERING |
false | Compare all token pairs, not only those with similar first sounds |
USE_SOUNDEX_MATCHING / SOUNDEX_BOOST_WEIGHT |
false / 0 | Soundex boost for every search |
FINAL_SCORE_LOW_COVERAGE_MULTIPLIER |
0.95 | |
FINAL_SCORE_MIN_REQUIRED_FIELDS_MULTIPLIER |
0.90 | |
FINAL_SCORE_NAME_ONLY_MULTIPLIER |
0.95 | Name-only penalty |
ID_CONFLICT_PENALTY_MULTIPLIER |
0.70 | Same ID type, different number |
NAME_NUMERAL_CONFLICT_PENALTY_MULTIPLIER |
0.70 | "Star 1" vs "Star 2" |
KEEP_STOPWORDS |
false | Keep words like "the", "of", "ltd" |
TFIDF_ENABLED (+ TFIDF_SMOOTHING, TFIDF_MIN_IDF, TFIDF_MAX_IDF) |
false | Weight rare name words higher. Requires a refresh after changing it. |
To try a different name algorithm for one search, use 'algorithm' => 'double-metaphone' (or soundex, beider-morse, editex, nsim, nsim-3, soft-bidist, soft-bisim).
21. Real-world recipes
Onboarding check in a controller
public function store(Request $request)
{
$data = $request->validate(['name' => 'required', 'dob' => 'nullable|date', 'country' => 'nullable']);
$result = Watchman::searchPerson($data['name'], array_filter([
'birthDate' => $data['dob'] ?? null,
'address' => $data['country'] ?? null,
'minMatch' => 0.80,
'limit' => 5,
]));
if (! $result->isEmpty()) {
// hold the account for manual compliance review
}
}
Screening a payment counterparty (business + IBAN country + website)
Watchman::search([
'type' => 'business',
'name' => $payment->beneficiary_name,
'address' => $payment->beneficiary_address,
'website' => $payment->beneficiary_website,
'minMatch' => 0.80,
]);
Crypto withdrawal check (exact address match scores 1.0)
Watchman::isMatch(['cryptoAddress' => 'XBT:' . $withdrawal->address], 0.99);
Re-screen all customers after each refresh
// e.g. in a scheduled job that runs after the refresh
Customer::chunkById(500, function ($customers) {
foreach ($customers as $c) {
$hit = Watchman::searchPerson($c->name, ['birthDate' => $c->dob, 'minMatch' => 0.80, 'limit' => 1])->first();
if ($hit) {
ScreeningAlert::firstOrCreate(['customer_id' => $c->id, 'list' => $hit->source, 'list_id' => $hit->sourceId], ['score' => $hit->match]);
}
}
});
Only re-screen when the lists actually changed
$hashes = Watchman::listInfo()['listHashes'];
if ($hashes != cache('watchman:last_hashes')) {
// lists changed → run the re-screen job
cache(['watchman:last_hashes' => $hashes]);
}
Vessel / shipping check
Watchman::searchVessel('Ocean Pioneer (ex-Cape Diamond)', ['imoNumber' => '9187629', 'flag' => 'Panama']);
22. Performance and sizing
These figures were measured on a small cloud VM with the Redis store:
| Data | Refresh time | Redis memory |
|---|---|---|
| OFAC SDN + US CSL (22.5k entities, full files) | 15–22 s | ~70 MB |
| OFAC, Non-SDN, CSL, EU, UK, UN, TEL (21.6k entities, Watchman test files) | ~25 s | ~76 MB |
Large OpenSanctions datasets (SAM exclusions has 100k+ records) add minutes and hundreds of MB.
Search times:
- Typical searches take 1–40 ms. The index narrows the scoring down to records that share name words, IDs, crypto addresses or address keys.
- Typo-only searches take longer. When no name word exists in the index (for example "Xzqv Wplk"), Watchman scores the whole partition for that type. That took about 0.9 s for ~13k people. Always pass
typeto keep the partition small. Enabling OPcache JIT (opcache.jit=tracing) makes scoring about 30% faster.
Refresh memory peaks at about 100–135 MB for the lists above. Set WATCHMAN_REFRESH_MEMORY higher for big OpenSanctions files.
23. Troubleshooting
| Symptom | Fix |
|---|---|
no sanctions lists are loaded; run php artisan watchman:refresh (HTTP 500) |
The index is empty: Redis restarted without persistence, was flushed, or no refresh has run yet. Run php artisan watchman:refresh, then check watchman:status. Searches refuse to answer rather than report "no matches". |
unknown lists: xyz |
A name in included_lists has no built-in source, OpenSanctions/Senzing entry or registered custom source. |
unknown list "…" in ignored_download_errors |
Only names of real sources can be ignored. |
a data refresh is already running |
Another refresh holds the lock. Wait, or clear watchman:refresh:lock from the cache if a worker was killed. |
| Refresh killed by the queue worker | Raise --timeout, retry_after and WATCHMAN_REFRESH_MEMORY. |
| Searches work, then suddenly return nothing | Redis evicted or flushed the index. Use a dedicated Redis with noeviction, then refresh. |
igbinary extension required to read this index |
The index was built on a machine with igbinary. Install it, or run a refresh. |
extra/unused query parameters in request: … |
Misspelled parameter (names are case-sensitive: birthDate, minMatch, sourceID). |
unknown algorithm |
Use one of the names in §8. |
| Ingested rows not found | Pass 'source' => '<fileType>' (Watchman behaviour). |
| Passport search doesn't score 1.0 | Use the list's country spelling (Russia:…, not RU:…). Check with debug. |
| Stale index generations in Redis/DB after crashes | php artisan watchman:gc |
/watchman/... routes return 404 |
Routes are off by default: set WATCHMAN_ROUTES=true and run php artisan route:clear. POST routes also need WATCHMAN_ADMIN_ROUTES=true and an auth middleware in routes.admin_middleware; otherwise a warning is logged and they are not registered. |
HTTP 500 internal error while processing the request |
A server-side failure (Redis or database down, corrupt index). The details are in the Laravel log. Bad input always returns 400 with the reason. |
Logs are written to your default Laravel log channel, prefixed watchman:.
24. How faithful is the port?
These parts are ported from the Go source:
- Name normalization and stopword removal, including whatlanggo's language detection and bbalet/stopwords' lists.
- Phone, country and domain normalization, and the US address parser.
- Jaro-Winkler and the alternative name algorithms, and every score piece.
- Candidate selection, following
internal/index: partitions, name-word postings with document-frequency pruning, ID and QWERTY-typo lookups, and hashed government-ID and address blocking keys.
The data tables are generated from the exact dependency versions Watchman pins (bin/generate-data.php).
Parity was checked against a harness compiled from the Go source (tools/parity):
- 16,989 OFAC, 5,512 CSL, 1,322 Senzing and 3 FinCEN entities map identically, including every prepared field.
- 6,000 randomized similarity comparisons match (all except one random language-detection tie).
- 25,000 name preparations, 15,000 Jaro-Winkler calls, 6,285 normalizations and 3,006 address parses match.
- The EU, UK, UN and US TEL readers pass the Go unit-test expectations.
- The only mismatches are a few short names where Go's own language detection is a random tie. Go itself returns different results between runs for those names.
Not ported (all optional in Watchman):
- AI-embedding cross-script search (OpenRouter).
- Geocoding.
- The libpostal / deepparse address parsers (the
usaddressfallback is used). - The web UI.
- MCP.
25. Testing and development
composer install
vendor/bin/phpunit # unit + feature tests (memory store)
WATCHMAN_TEST_STORE=redis vendor/bin/phpunit # against Redis (REDIS_HOST/REDIS_PORT)
WATCHMAN_TEST_STORE=database vendor/bin/phpunit # against SQLite
To regenerate the language, stopword, phone, ISO-3166 and public-suffix tables after upgrading upstream dependencies:
php bin/generate-data.php <go-dump.json> <bbalet-stopwords-dir> <watchman/internal/prepare/union_stopwords.go> <public_suffix_list.dat>
(bin/datagen/main.go produces go-dump.json.) Parity tools and instructions are in tools/parity/README.md.
Code map
src/
Search/ entity models, Similarity (scoring), QueryParser, SearchService, Merge
StringScore/ Jaro-Winkler, Soundex, phonetic filter, alternative algorithms
Prepare/ name normalization, stopwords, language detection, legal forms, ex-names…
Norm/ country, phone, website, domain (PSL)
Address/ US address parser
Sources/ Ofac, CslUs, FinCen311, CslEu, CslUk, CslUn, UsTel, Senzing
Download/ Downloader, ListRefresher, ListSource
Index/ candidate selection + Redis / Database / Memory stores
Ingest/ CSV / Senzing file ingestion
Laravel/ service provider, facade, clients, controllers, commands, job
config/watchman.php routes/watchman.php database/migrations/ resources/data/
Contributing
Issues and pull requests are welcome. Every change is reviewed and approved by the maintainer before it is merged. See CONTRIBUTING.md, and report security problems privately as described in SECURITY.md.
26. License
Apache-2.0, like moov-io/watchman (see LICENSE and NOTICE). The generated data in resources/data comes from:
- bbalet/stopwords (BSD)
- abadojack/whatlanggo (MIT)
- dongri/phonenumber (MIT)
- moov-io/iso3166 (Apache-2.0)
- the Public Suffix List (MPL-2.0)
Their license texts are in the licenses/ folder.