Insights
Finding a counselor in Oregon

Somebody opens the directory at nine at night, after a week they would rather not describe out loud. They have one word for what is wrong, the name of their town, and about a minute of patience. Every decision I have made in ORCounselors.com sits behind that minute.
ORCounselors.com is my own product, a mental health directory for Oregon. On 4 September 2026, the day I read every number in this post off the live site, it listed 16,798 provider profiles across 320 Oregon cities. Every count in this post was read that day, and several of them moved by one or two while I was writing, which is what a live directory does. I have written before about getting providers to claim their profiles and about the organic search that brings people to those pages. This post is about the part in between, the search itself: what a person types, what the code does with it, and which of those choices I would defend to a regulator. There is a case study for the platform and a separate one for the iPhone app.
What does a person actually type?
Two things. A word for the problem, and a place.
So the directory at /providers is two boxes and nothing else. The first is labelled "Search by specialty, name, or keyword" and it accepts all three: a need, a treatment approach, an insurance carrier, or the name of a clinician somebody was given by a friend. The second is "City or ZIP". The homepage offers a shorter version for people who would rather pick than type: a Therapy or Medication toggle, an insurance dropdown, and a "Need" list of ten plain words, submitting to the same URL.
What happens to the typed string is deliberately boring. It is lowercased, split, stripped of stopwords, and anything shorter than three characters is dropped, using the same tokenizer as the site-wide search so the two surfaces never disagree about what a query means. Each surviving token then has to match either the inverted index of provider text or a direct column match on name, credentials, city and ZIP. The direct match is the part that matters on a slow day: a clinician verified an hour ago is findable by name before the index rebuilds.
One special case earns its keep. If the whole query is a carrier name, "Kaiser" or "PacificSource", it routes through the insurance path instead, which knows every spelling variant. A keyword index tokenises per word, so "PacificSource" as one token would quietly miss a provider tagged "Pacific Source Commercial". Mixed queries like "aetna anxiety" keep their specialty intent.
Why is there a second door?
Because the hardest search to answer is "I do not know what I need, I just know I need help", and no filter panel has ever answered it.
So there is a guided wizard at /match. The site describes it as free, private, and about five minutes, and it is six steps: medication, concerns, insurance, format, location, gender of the clinician. Medication comes first because it is the cheapest fork in the road. If someone needs a prescriber, most of the directory is the wrong answer and the rest of the questions are asked against a much smaller pool.
The concerns step is where a decade of intake and crisis work shows up in the code. The pattern I could not unsee, doing assessments in residential treatment and in a jail, is that the people who most need care are the least likely to arrive holding the vocabulary a form expects. Nobody in a bad week describes themselves as presenting with generalised anxiety disorder. So the wizard asks in plain words, and each plain word expands into the vocabulary clinicians use in their own profiles:
'anxiety' => ['anxiety', 'stress', 'gad', 'panic', 'worry',
'phobia', 'social anxiety', 'nervous'],
'depression' => ['depression', 'mood', 'depressive', 'dysthymia',
'seasonal affective', 'sadness', 'hopelessness'],
Somebody who taps "Anxiety" is searching for panic, phobia, worry and nervous at the same time, and never has to know it.
How does the scorer decide?
Both doors hand their answers to the same piece of code, and it counts points.
A matched concern is worth three points. Accepted insurance is worth five, and insurance the profile does not list costs three, as a soft penalty rather than an exclusion, because insurance panels change faster than profiles do. A requested clinician gender is five. Being in the searched city, or sharing the first three digits of the ZIP, is six. A prescriber, when somebody asked for medication, is fifteen, which is the largest single value in the table and the only one I would call a hard signal.
The interesting part is the denominator. It is not a fixed maximum, it is the highest score this particular set of answers could have produced, so questions nobody answered are never counted against anybody. Twenty one points is seventy percent on a short quiz and thirty five percent on a detailed one. A profile has to clear three points, one real signal, before it counts as a match at all, and the displayed percentage is clamped:
$pct = max(1, min(99, $pct)); // Never show 0% or 100%, both feel wrong to users.
That comment has been in the file since I wrote it and it is still the honest reason. A hundred percent match is a promise nothing in this dataset can keep. The same scorer also badges the typed search results, and returns nothing at all when a query carries no real intent, so a bare listing page never wears percentages it did not earn.
What do the access filters do?
They are plain URL parameters, which is the whole design.
?ohp=1 for the Oregon Health Plan, ?telehealth=1, ?sliding_scale=1, ?accepting=1 for providers currently taking clients. The chips under the homepage search are ordinary links to those URLs, which means a caseworker can bookmark one, a hospital discharge planner can paste one into an email, and every one of them is a page a search engine can find. On 4 September 2026 they returned 3,097, 8,809, 190 and 527 profiles respectively.
Location behaves the way people expect and not the way a database would. A city search covers roughly thirty miles, and then adds verified telehealth from anywhere in Oregon, in one clause:
if (!empty($f['include_telehealth_statewide']) && empty($f['in_person'])) {
$qb->where("({$inRadius} OR (is_telehealth = 1 AND is_verified = 1){$cityClause})",
$cityParams);
}
The city text match sits alongside the radius rather than inside it, because stale imports leave some profiles with a wrong coordinate and a right city name, and a provider whose pin is in the wrong county should not vanish from their own town.
When does an unclaimed profile outrank a verified one?
When somebody has said they need to sit in a room with a person.
A filtered search sorts every match into four zones before anything else is looked at, and the zone order is not one thing, it is two. On a default search, one carrying a city or a specialty or a carrier but no in-person requirement, the zones run: verified and local, then verified anywhere in Oregon, then local profiles nobody has claimed, then the rest. Verification wins first and locality second, and the comment on that branch justifies the order by how somebody reads a page of results: who is here and vouched for, who is available on video, who else is nearby.
Turn on the in-person filter and the middle two swap. A telehealth-only clinician cannot see anybody in a room however verified they are, so the order becomes verified and local, then local, then verified statewide, then the rest, and an unclaimed profile in the searched town moves above a verified provider at the other end of the state. Nothing sorts across a zone. Inside one, the order is city-text closeness, then whether the profile has been claimed, then distance, then relevance, then a score that folds in subscription tier.
The comment at the top of that function has said this since I wrote it:
This means: a verified Portland-only telehealth provider never outranks an unclaimed Ashland shadow profile on an Ashland search. Locals surface first; statewide is a tail.
Reading it back for this post, I found that it describes the in-person branch while sitting above both of them. The code does what I want on either path. The comment outgrew it, and that sentence is only true of the search where somebody asked for a room. Worth naming, because a comment is the only part of a ranking most people will ever read.
Money buys something on this site, and it is worth being exact about where. The homepage Featured placements are paid and pinned by hand, one at a time, in an admin screen that writes promotion rows. Browse the directory with no filters at all and those featured and standout flags sort first, above verification and above everything else. Type a city or a specialty and they fall to almost the bottom of the sort, below relevance and below the score, while subscription tier stops being a placement and becomes one addend inside that score, which orders providers within a zone and cannot move one out of it.
The reason to be strict about either order is not modesty. In May 2023 the Senate Finance Committee's majority staff published a secret shopper study of mental health listings in Medicare Advantage directories. They called 120 listings across 12 plans in 6 states. Thirty three percent were inaccurate, non-working, or never returned the call. Staff could book an appointment 18 percent of the time, and the report notes that the rate ranged "from 0% in Oregon to 50% in Colorado". Two years later, in August 2025, the New York Attorney General settled with an insurer after finding that "100 percent of the mental health providers it called, all of whom were listed as 'accepting new patients,' were either unreachable or not currently accepting new patients". That release does not give a sample size, which is worth saying rather than borrowing the number's weight.
Zero percent in Oregon is the sentence I built around. A directory is a claim about who is reachable, and the cost of a wrong claim lands on the person least able to absorb it.
Why do the menu counts differ from the page counts?
Because they are counting different things, and pretending otherwise would be the easiest lie on the site.
The navigation menus and the footer count verified providers only, and hide any specialty or city with fewer than six of them, so the site never advertises a hub page that would open onto nothing but unclaimed rows. That is why, on 4 September 2026, the menu showed 393 verified providers for anxiety while /specialties/anxiety reported 2,016 providers found, and why the menu showed 237 for Portland while /oregon/portland reported 4,358 within about thirty miles plus statewide telehealth.
The two totals in the footer, "providers listed" and "verified members", are worded differently on purpose, and the note beside the query that produces them says so: they are "kept separate from the total listed count so the two are always labeled differently and never read as contradictory". A licensed clinician who has never logged in is a real public record. They are not a member. Blurring the two would make the bigger number look like the smaller one, which is the move the ghost network studies are about.
What happened when the search got popular?
Crawlers found out that every filter combination is a URL.
On 4 August 2026 the host sent an abuse report. Bots had walked the paginated results to OFFSET 4680, and every one of those pages ran the full ranking sort over the matched set before throwing away the rows nobody was reading. The query was also selecting whole profiles, so bio text was being dragged through the filesort, roughly three and a half seconds a page on shared MySQL under load. Two changes fixed it. The sort now runs over ids alone and the page's rows are fetched by primary key afterwards, which is about four times cheaper and provably the same order. And the browsable window is clamped:
// Crawl-depth cap: every page of results runs the full deck-order
// filesort, so deep OFFSETs burn ~3.5s each for rows nobody reads
// (bots walked to OFFSET 4680 in Hostinger's 2026-08-04 abuse
// report). Clamp the browsable window to 1000 rows.
$page = min($page, max(1, (int)ceil(1000 / $perPage)));
The general lesson is one I have since applied to every faceted surface I run. A public search page is not a page, it is a compute endpoint with a query string, and pagination is a promise about how much of the database a stranger is allowed to make you sort. Decide that number on purpose, before somebody else decides it for you.
What is on a profile, and what is on the phone?
A profile is built to answer the three questions people actually ask before they call: can you help with this, will you take my money, and are you taking anyone new.
Specialties and treatment approaches sit high on the page as tags rather than prose, because a tag is what a filter turns into and the reader should see the same shape the search saw. Pricing, insurance, ages, languages and session format live in a fixed rail, in the same order on every profile, so comparing two clinicians is scanning rather than reading twice.
The same directory is a free iPhone app, OR Counselor Directory, first released on 5 July 2026 and on version 2.4.0 as of this writing, minimum iOS 17. It has no account and no login, and its store listing is blunt about the boundary: "This app is a directory only. It does not provide medical advice, diagnosis, or treatment." The part I care about most is the smallest. The crisis numbers, 988 and the Crisis Text Line and the Veterans Crisis Line and the Trevor Project, are bundled into the app rather than fetched, so they work with no signal. A directory that needs a connection to hand somebody a phone number has failed at the only moment that is not negotiable.
The number I want to move
On 4 September 2026, 190 of 16,798 profiles said sliding scale. The filter works. It returns exactly the people who said it, and it will keep doing that whether the number is 190 or 1,900.
That one is not an engineering problem, which is a strange thing to admit at the end of a post about search. What I do not know yet, and what I would like the next version to be able to answer, is how many of those 190 still had an opening the week somebody clicked.
Common questions
How do I search for a counselor in Oregon?
Type a need, a specialty or a clinician name in the first box and a city or ZIP in the second. A city search covers roughly thirty miles and adds verified telehealth providers from anywhere in Oregon.
What is the difference between the search and the match wizard?
The search is for people who can already name something. The wizard at /match asks six questions in plain words, takes about five minutes, and scores every provider against your answers instead of making you build a filter.
How do I find a therapist who takes the Oregon Health Plan?
Add ?ohp=1 to the directory URL, or use the Accepts OHP chip under the search box. On 4 September 2026 that returned 3,097 profiles. Sliding scale, telehealth and currently accepting are the same kind of plain URL parameter.
Does paying for a listing move a provider up the results?
Not on a filtered search. Filtered results bucket into four zones first, verified and local, then verified statewide, then local, then everyone else, and nothing sorts across a zone, so subscription tier is only one addend in the score that orders providers inside one. Money does buy the homepage Featured placements, which are paid and pinned by hand, and browsing with no filters at all sorts featured and standout profiles first. Turn on the in-person filter and local moves above verified statewide.
Is there an app?
Yes, OR Counselor Directory for iPhone, free, iOS 17 and up, with no account and no tracking. The 988 and crisis line numbers are bundled into the app so they work without a connection.
Related