It’s time to review a few best practices when it comes to naming my REST API paths. I’m a bit new to this, but I’ve been following some of the practices already. Things like, don’t use file extensions, put variable names in the path, and use GET, POST, PUT, PATCH, DELETE request methods, which are also called HTTP Verbs.

Microsoft Designer: Image Creator Prompt
Create a “Rest API” logo. I usually see the logo with a gear sitting at the top of a cloud, with the text “Rest API”. This is often used to send data between devices using JSON.
For starters, I’m looking at REST API URI Naming Conventions and Best Practices on the Restful API website. Let’s follow along.
1.1 Singleton and Collection resources
I already named the folders of my endpoint folders after the collection, in plural form. However, I was adding on the file name as well.
GET /api/users/find/0/10
GET /api/users/find.php?offset=0&limit=10
users is the collection, so find is just a duplication of saying you want to look in the users collection. In addition, the variables after it can not be distinguished from requesting an individual user, so they need to be put back as query string variables.
/api/users?offset=0&limit=10
Next is the singleton for one user.
/api/users/get/123
/api/users/get.php?id=123
The id of 123 itself is the singleton. I need to correct the path to be:
/api/users/1
1.2 Collection and Sub-collection resources
In a relational database, you often have relationships in your tables, where a record can’t exist in one table, without referencing another tables record. This is part of normalization to prevent duplicate data in the database and to keep the size of the database small.
Now I was going about this differently. I was going after a new collection. So let’s say I had users, and those users had pets. We’ll assume that the prior convention from 1.1 has already been applied.
/api/pets?userId=123
/api/pets/432
We have a few calls made to get a list of pets for a given user, and to get the individual pet. Notice that I had to provide a userId as a query string parameter. The proper way to implement this would be as a sub-collection off of users.
/api/users/123/pets
/api/users/123/pets/432
I’m starting to see a pattern here of /{name}/{value} that may be translated to ?{name}={value}. I’ll have to put that on the back-burner to see if it still applies later.
1.3 URI
This doesn’t really seem to say much in what to do, other than making sure the naming of your API makes sense. I think the fact that our collection names represent the data in the database is a good indication that we are on the right track.
2.1 Use nouns to represent resources
This falls back to how I name data within the database. 99% of the time, you name things after nouns. In some scenarios, verbs are used as nouns. The added benefit of nouns is that usually they have separate singular/plural form, which helps when naming fields for a collection vs. singleton. For example, a table named Pets may have a userId to represent a single owner in the Users table.
One last thing this section talks about is resource archetypes and categories.
/{document}/{collection}/{store}
It seems like the “store” is the singleton. What is “document” in this context?
2.1.1 Document
Document represents a single record in a database (or wherever your collection of resources come from). So for me, that would be the Users an Pets tables. But they are adding that this should be singular, and adding it before the collection.
/api/membership/users/123
I think the confusion lies is that the Users and Pets are the table names in our database, so the “document” feels like a broader categorization rather than an individual record. Especially since you don’t access a single record of a document.
2.1.2 Collection
This just goes on about what we already covered about collections, and that they could be a server directory – which I how I’m setup. I think a collection may be more like Table Views or Stored Procedures in databases. They don’t necessarily represent a tables data directly, but may consolidate multiple tables together. In this scenario, I could have multiple collections of membership, but with different types of data provided.
/api/membership/users/api/membership/pet-owners
In this scenario, both users and pet-owners provide data from the Users table, but pet-owners includes the data or identity of any pets that the user may have. To reflect my database structure, perhaps I should have named it like this.
/api/user/users/api/user/pet-owners
The confusion also lies in the fact that database table names are often in plural form because they represent many records. The confusing part now is that we have a users collection on a user document. Is there a default name to provide for documents? Can you request the document directly? If so, then it seems to break the {collection}/{singleton} pattern since the document is not plural, and the collection is.
2.1.3 Store
I had thought maybe the singleton was the store, where 123 in /api/user/pet-owners/123 was the store. It seems I am incorrect. The store seems to be the sub-collection, and it must be plural. For example, “pets” is the store in the following URI.
/api/user/pet-owners/123/pets
2.2 Consistency is the key
No information…
2.2.1 Use forward slash (/) to indicate hierarchical relationships
This is how I understand how the collection/sub-collection works, as denoted previously. How do nested relationships work? I have a menu that can reference another record in the same table. Do I add sub-collections infinitely?
/api/menu/types/1/children/23/children/56/children
I don’t like this deep nesting. I’d much prefer to go back after /api/menu/types/56/children since that part of the front-end code doesn’t need to maintain a reference to it’s parent & grandparent id. In addition, the ancestry id’s may change – but the data for 56 does not change. However, if I need to maintain id’s of 1 and 23, then I’ll have to make an unnecessary request to the server for data that doesn’t change. In addition, someone could enter any value they want in place of 1 and 23, but it’s not used by the underlying code to fetch the children. In fact, doing so would require an extra trip to the database.
2.2.2 Do not use trailing forward slash (/) in URI’s
This is a given. Although I can compensate for it, it adds resources to look for and correct it. A trailing forward slash has a specific meaning that you are looking at a directory rather than a resource.
/api/menu/types/ – bad/api/menu/types – good
2.2.3 Use hyphens (-) to improve the readability of URIs
This one got me. With PHP it seems that snake case is used everywhere in function names and file names, so I naturally followed that pattern.
/api/user/pet_owners – bad/api/user/pet-owners – good
They talk about browsers and fonts obscuring or hiding the underscore. I haven’t seen this behavior, and REST API’s are more for computer systems and developers to debug. The end-user rarely sees them.
2.2.5 Use lowercase letters in URIs
I was already abiding by this convention and using underscores to delineate words. Lower-case is easier to read.
2.3 Do not use file extensions
This was one of the first things I implemented. The problem with file extensions is that they don’t convey any information. In addition, if you ever move to a new language, the legacy extension still has to be mapped so that old requests to your api will still work. If you are serving a resource such as an image or audio file, it should be indicated as such via the content-type header. In cases where I had to serve an inline PDF, I found that most modern browsers will ignore the file extension unless the appropriate content-type header is served.
2.4 Never use CRUD function names in URIs
Ouch! They got me. Naturally, I name individual files for each action. The use of HTTP verbs confused me a bit, but now they make sense as to why they are necessary. To the end-user, the Read/Update/Delete operations all point to the same URI. CRUD operations are Create, Read, Update, and Delete.
| Method/Verb | Path | CRUD |
|---|---|---|
| GET | /api/user/pet-owners | Read All |
| POST | /api/user/pet-owners | Create |
| GET | /api/user/pet-owners/1 | Read |
| PUT | /api/user/pet-owners/1 | Update |
| DELETE | /api/user/pet-owners/1 | Delete |
There is also another Method/Verb for PATCH that is used to update part of a record rather than the entire record. Given that a {document} can have multiple {collections}, this may be unnecessary as a collection may limit the fields that may be updated anyway. We’ll see if they mention PATCH later.
2.5 Use query component to filter URI
I was getting confused about this for my searches. I have four parameters – limit, offset, search, and parentId. The parentId has to do with hierarchy, and is already addressed with {sub-collections}. Last night, I made all parameters as part of the path, but it was confusing as to what those segments represented. The first entry in this post, it was immediately apparent that I had a problem and promoted the extra segments back to query string parameters. Now that we also have hierarchy addressed as sub-collections, I can combine the two as such.
GET /api/menu/types/1/children?limit=10&offset=0&search=
3. Do not use verbs in the URI
This isn’t limited to HTTP Verbs alone. This is all verbs. The following URI is not compliant with the naming conventions.
GET /api/user/pet-owners/1/invite
Verbs are instead posted to the singleton.
POST /api/user/pet-owners/1{ action: "invite"}
This means that you can setup multiple verbs on a single resource without having to create new resource paths for each verb.
4. Conclusion
Resources are nouns.
So my take on this document is that the HTTP Method (GET, POST, PUT, DELETE) is the action to perform on those nouns. It’s generally a CRUD based API sitting on top of HTTP methods, with the addition of actions categorized as “Create” operations on the individual records.
So what did we learn
| Action | Method | Path |
|---|---|---|
| Get users | GET | /address-book/people |
| Get filtered users | GET | /address-book/people ?limit=10&offset=0&search=foo |
| Create user | POST | /address-book/people |
| Get user | GET | /address-book/people/1 |
| Update user | PUT | /address-book/people/1 |
| Delete user | DELETE | /address-book/people/1 |
| Action on user | POST | /address-book/people/1 |
| Get user pets | GET | /address-book/people/1/pets |
| Add user pet | POST | /address-book/people/1/pets |
| Get user pet | GET | /address-book/people/1/pets/1 |
| Update user pet | PUT | /address-book/people/1/pets/1 |
| Delete user pet | DELETE | /address-book/people/1/pets/1 |
| Get users with pet data | GET | /address-book/pet-owners/1 |
Let’s see how this applies to Authentication & Authorization
| Action | Method | Path |
|---|---|---|
| Register | POST | /authentication/credentials {username: “lmoten”, password: “foo”} |
| Login | POST | /authentication/credentials/lmoten {action: “login”, password: “foo”} |
| Logout | DELETE | /authentication/credentials/lmoten/tokens/tokenid |
| Refresh Token | POST | /authentication/credentials/lmoten/tokens |
| Change Password | POST | /authentication/credentials/lmoten { action: “change password”, currentPassword: “foo”, password: “bar” } |
From what I see, we are posting various actions to be performed on the credentials when logging in, out, and refreshing JSON Web Tokens. The problem here is that we have several POST actions that do different things for the same path. Apache rewrite module only inspects the headers, so it can’t inspect the action and change which file it will rewrite the url to process. We still need to perform further processing on the request.
I had a bit of confusion over “Change Password”. Since it’s updating a record, it seemed like it should have been a PUT operation. However, it includes additional information, current password, which would result in subsequent actions failing with the same data. I settled on a POST operation against the singleton instead.
In addition, I was considering “Logout” to be a DELETE operation on the credentials. I then changed to a POST action. This is because nothing is being deleted. The credentials still exist. Reflecting even further, I realized that my tokens were being deleted/revoked during the logout process, so I created another collection with the DELETE operation. Afterwards, the refreshing tokens were changed to POST to /authentication/tokens.
Now, refresh tokens brought up an interesting problem. Yes, I’m creating a new token – but its for a specific user. Should I POST to /authentication/tokens/lmoten or /authentication/tokens ? As I continued to think about it, tokens is a sub-collection of credentials. Once I had created tokens as a sub-collection, it meant that logging out needed a token id. JWT has a claim, “jti” that is used to identify the token. I hadn’t made use of it yet, but this seems like a good reason to do so. The client can read the id and provide that when removing the token.
The other part of the puzzle to consider is that when logging out, I have two separate tokens. An authorization token, and a refresh token. Should I make two separate calls? Should I delete the refresh token, and assume the authorization token was deleted as a cascading effect? For the time being, I went with the later, but provided the tokens in the body of the request.
Here is how I setup the API Slice to logout in RTK Query.
logout: builder.mutation<
void,
{
authorizationToken: string | undefined,
refreshToken: string | undefined
}
>({
query: ({
authorizationToken,
refreshToken
}) => {
const claims = jsonWebToken(refreshToken);
const username = claims.subject;
const tokenId = claims.id;
return ({
url: `/authentication/credentials/${username}/tokens/${tokenId}`,
method: 'DELETE',
body: {
authorizationToken,
refreshToken
}
})},
}),
I’m still sending over the full tokens in the body, as they may have additional information, and I can verify their content hasn’t been modified by the client so long as they have their signatures.
Now we go back to how tokens are refreshed and realize that we have a hierarchy of tokens.
- Authentication
- Credentials
- Refresh Tokens
- Authorization Tokens
- Refresh Tokens
- Credentials
If a user logs out of a browser with a refresh token, we need to revoke the authorization tokens associated with that refresh token. However, we need to allow all other browser sessions to remain valid.
Let’s re-evaluate the paths for tokens
| Action | Method | Path |
|---|---|---|
| Logout | DELETE | /authentication /credentials/lmoten /refresh-tokens/123 |
| Refresh | POST | /authorization /credentials/lmoten /refresh-tokens/123 /authorization-tokens |
Our paths are growing. We don’t necessarily need /credentials/lmoten in order to logout or refresh our tokens. Furthermore, these tokens are specific to authorization. Let’s refactor the path to reflect the changes
| Action | Method | Path |
|---|---|---|
| Logout | DELETE | /authorization/refresh/123 |
| Refresh | POST | /authorization/refresh/123/tokens |
Well, that’s not going to work. “Refresh” is a verb.
| Action | Method | Path |
|---|---|---|
| Logout | DELETE | /authorization/refresh-tokens/123 |
| Refresh | POST | /authorization/refresh-tokens/123/authorization-tokens |
Implementation is hard.
- I fought with .htaccess patterns for a good while before just using the Fallback for everything
- PHP regular expressions look like strings, but they require beginning and trailing forward slashes, and all forward slashes within need to be escaped with a back slash
- You can’t echo a pattern variable such as
echo "Pattern: $pattern";. You need to escape the pattern. Theaddslashesfunction does this for you.echo "Pattern: ".addslashes($pattern); - Browsers like to cache data sometimes, even when you tell them not to. This leads to the impression that changes being made on the server are not having any effect.
- In order to follow the REST naming scheme, you have to inject data into your payload in order to carry out actions against your resources. In some situations, you may have a bulk action that doesn’t have a specific item that it works against. I was posting an array of objects with new orders
{id, parentId, order}. However, I now need to change the request into to an object so that in addition to the array, I can post theactionthat identifies what I want to do with the array. This information is no longer part of the URI. - Posting to the collection may be more ideal, but that would indicate that a singleton is being created. For now, I’m just taking the identity of the first item in the collection and posting against that. The actions could be split individually, but in my scenario, these need to be an Atomic transaction. I have a list of items that need to be moved, where their
orderandparentIdfields may change. Either all of them succeed, or changes need to be rolled back if one fails. We can’t trust the client to do this. For example, they have 100 items that need to be updated. If an error happens in the middle of that update, and then they lose power – how is that going to be rolled back? REST is stateless.
One of the safe guards I have in place, when I read JSON data that was posted to the API, is to limit the maximum depth.json_decodelimits this to 512 by default. I’ve been aggressively taking it down to the bare minimum. Now that the action needs to be read before sending it to the correct file to process the data, I need to open this up a bit wider to satisfy all paths. - Something is odd about a path that has
credentialsin the name. My pre-flight OPTIONS request worked fine. However, any time I posted data to it, I would get a301 Moved Permanentlystatus code. The browser would specify the following:Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.I couldn’t find anywhere in my code that issued a 301 status code, or the HTTP_STATUS_MOVED_PERMANENTLY constant. Once I renamed fromcredentialstoaccounts, everything worked fine. Renaming back tocredentialsconfirmed that the problem still existed. It’s such an odd problem.
Everything is converted over to the REST API naming conventions now. There are things I don’t like. As a developer, I’m monitoring traffic. Thankfully I can monitor the request methods. However, I don’t know specifically what the POST requests are for unless I inspect the payloads.

That’s pretty much my biggest gripe at the moment. Verbs/Actions are not part of the path, so it’s difficult to discern what is happening.
My .htaccess file is much cleaner.
FallbackResource /api/routes.php
# Stop rewriting if file exists
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L,QSA]
My folders have been renamed to match the {document} portion of the API calls. Inside those documents, I have a _routes.php file. It’s sole purpose is to describe all routes, methods, mappings, and how to convert path variables into query string parameters.
authentication/_routes.php
<?php
function max_posted_json_nest_depth()
{return 3;}
function get_routes()
{
return [
'/accounts$/' => [
'POST' => 'register.php',
'GET' => 'list.php',
],
'/accounts\/([^\/]+)$/' => [
'GET' => ['get.php', 'username'],
'POST' => [
'action' => [
'login' => ['login.php', 'username'],
'change password' => ['change-password.php', 'username'],
],
],
],
];
}
authorization/_routes.php
<?php
function get_routes()
{
return [
'/refresh-tokens\/([^\/]+)$/' => [
'DELETE' => 'logout.php',
],
'/refresh-tokens\/([^\/]+)\/authorization-tokens$/' => [
'POST' => ['refresh.php', 'refreshId'],
],
];
}
classification/_routes.php
<?php
function max_posted_json_nest_depth()
{return 5;}
function get_routes()
{
return [
'/types$/' => [
'GET' => 'find.php',
'POST' => 'add.php',
],
'/types\/([^\/]+)$/' => [
'GET' => ['get.php', 'id'],
'DELETE' => ['delete.php', 'id'],
'PUT' => ['modify.php', 'id'],
'POST' => [
'action' => [
'move' => ['move.php', 'id'],
'rename' => ['rename.php', 'id'],
'reorder' => ['reorder.php', 'id'],
],
],
],
'/types\/([^\/]+)\/features$/' => [
'GET' => ['features.php', 'id'],
],
];
}
So here is the format.
- The routes are an associative array representing each valid route.
- The key is a regular expression/pattern that matches the route, based on the path starting from the current folder. For example, if I go to /api/authentication/accounts/lmoten, the path will be truncated to have
/api/authentication/removed. The regular expressions will only be ran againstaccounts/lmoten. - The pattern will have an associative array of HTTP Methods that can be mapped.
- A method can point directly to a file as a string. ie –
'GET' => 'get.php'will map directly toget.php. - A method can map the capture groups in a regular expression as query string parameters.
['get.php', 'username']assigns the first capture group in the regular expression as?username=lmoten- If you want to skip over some of the capture groups, specify
nullfor the capture. ['get.php', null, 'username']would skip the first capture group, and apply the second capture group as the username.
- A method can evaluate the JSON content. It’s very simple as it will accept the first key/value pair that matches.
['action' => ['login' => ['login.php', 'username']]]will look at the data within the request body, evaluate it as JSON, look for a key namedaction, and compare it to matchlogin. If so, then the same rules previous apply to the mapping.
Since I need to read the JSON content, I needed to add an extra function to specify deeper nesting depths for the document as a whole.
Here is how I load the routes in my fallback script.
api/routes.php
<?php
require_once dirname(__DIR__) . '/common/PostedJson.php';
require_once dirname(__DIR__) . '/common/Show.php';
require_once dirname(__DIR__) . '/common/HTTP_STATUS.php';
function map_route()
{
$basePath = '/api';
$uri = $_SERVER['REQUEST_URI'];
$uri = trim(str_replace($basePath, '', $uri));
$uri = ltrim($uri, '/');
$segments = explode('/', $uri);
if ($uri === 'verify-db') {
require 'verify-db.php';
return;
}
$document = $segments[0];
$file = "$document/_routes.php";
if (file_exists($file)) {
array_shift($segments);
require $file;
map_document_routes(get_routes(), $document, $segments);
return;
}
Show::message(
"Matching route was not found.",
HTTP_STATUS_NOT_FOUND
);
}
function map_document_routes($routes, $document, $segments)
{
$path = implode('/', $segments);
foreach ($routes as $pattern => $route) {
if (preg_match($pattern, $path, $matches)) {
map_matching_document_route(
$pattern, $route, $document, $matches
);
return;
}
}
Show::message(
"Matching route was not found in $document",
HTTP_STATUS_NOT_FOUND
);
}
function attempt_to_import_with_route_info(
$condition, $document, $info, $matches
) {
if (is_string($info)) {
$file = "$document/$info";
if (file_exists($file)) {
require $file;
} else {
Show::error(
"$condition missing file: $file",
HTTP_STATUS_NOT_IMPLEMENTED
);
}
return true;
}
if (!is_array($info)) {
Show::error(
"$condition expected to be string or array.",
HTTP_STATUS_INTERNAL_SERVER_ERROR
);
return true;
}
if (is_string($info[0])) {
$file = $info[0];
$file = "$document/$file";
if (!file_exists($file)) {
Show::error(
"$condition missing file: $file",
HTTP_STATUS_NOT_IMPLEMENTED
);
return true;
}
foreach ($info as $index => $key) {
if ($index === 0 || $key === null) {
continue;
}
$_GET[$key] = $matches[$index];
}
require $file;
return true;
}
return false;
}
function map_matching_document_route(
$pattern, $route, $document, $matches
) {
$method = $_SERVER['REQUEST_METHOD'];
$allowed_methods = array_keys($route);
$allowed_methods[] = 'OPTIONS';
$allowed_methods = array_unique($allowed_methods);
sort($allowed_methods);
$methods = implode(', ', $allowed_methods);
$headers = 'Content-Type, Authorization, Cookie';
header("Access-Control-Allow-Methods: $methods");
header("Access-Control-Allow-Headers: $headers");
header('Access-Control-Allow-Credentials: true');
header("Access-Control-Allow-Origin: *");
if ($method === 'OPTIONS') {
http_response_code(HTTP_STATUS_OK);
exit;
}
if (!isset($route[$method])) {
Show::status(HTTP_STATUS_METHOD_NOT_ALLOWED);
return;
}
$info = $route[$method];
$condition = "Route $document/" . addslashes($pattern) .
"[$method]";
if (attempt_to_import_with_route_info(
$condition, $document, $info, $matches
)) {
return;
}
if (!is_array($info)) {
Show::error(
$condition . " expected to be string or array.",
HTTP_STATUS_INTERNAL_SERVER_ERROR
);
return;
}
if ($method !== 'GET') {
$depth = 3;
if (function_exists('max_posted_json_nest_depth')) {
$depth = max_posted_json_nest_depth();
}
$posted = new PostedJson($depth, $method);
}
foreach ($info as $key => $keyValues) {
if ($posted->keyExists($key)) {
$posted_value = $posted->getValue($key);
foreach ($keyValues as $value => $subInfo) {
if ($posted_value === $value) {
$condition .= "[$key][$value]";
if (attempt_to_import_with_route_info(
$condition,
$document,
$subInfo,
$matches
)) {
return;
}
Show::error(
$condition .
" expected to be string or array.",
HTTP_STATUS_INTERNAL_SERVER_ERROR
);
return;
}
}
}
}
Show::status(HTTP_STATUS_PRECONDITION_FAILED);
return;
}
map_route();
The beauty of this is that I no longer have a blanket options handler that says any resources is available with any method. Now that I have mapped the routes to methods, I compose a unique list of the methods and state that they are all allowed.
Where to go from here. Well, I’ve only read one document regarding REST API. It’s got comments from six years ago. I’m sure things have matured further since then, and I may find a few more things about REST API’s in general. One thing that I’m considering is to return all identifiers as strings. It would make it easier on the front-end code if I ever changed from numbers to UUID’s or named slugs, as the front-end doesn’t really need a reason to have numbers instead of strings. Since ID’s are part of the path, it restricts what characters can be within our id’s, unless we encode them. I think I would want to avoid that as a whole.
Another thing I’m thinking of is the use of regular expressions. I’m not quite sure they are really necessary, as everything is based on path segments. I could match segments rather than patterns.
The evaluation of the request still bothers me, as to what file the request will be mapped to. It would be simpler to append a query string to the URI with the action. POST /api/authentication/accounts/lmoten?action=login. Still, query string parameters don’t show up. Perhaps I’m thinking about it wrong. What if I just post to /api/authentication/logged-in-accounts? I’m thinking something similar for renaming the types could be PATCH /api/classifications/types/32/name
Here’s a thought I’ve been having. If I have multiple {collections} under a {document}, is it expected that the same ID can be used for each collection under that document?
- /api/authentication/accounts/37
- /api/authentication/something-else/37
It seems like that may be the intent of the REST API naming conventions with documents vs collections. The document (authentication) represents what you are going after with your identifier. The collection is just a specific view of that document.
