spkm / isams-api
Integrate with the iSAMS API using SaloonPHP
Requires
- php: ^8.5
- illuminate/cache: ^12.39||^13.0
- illuminate/collections: ^12.39||^13.0
- illuminate/contracts: ^12.39||^13.0
- illuminate/support: ^12.39||^13.0
- saloonphp/cache-plugin: ^3.0
- saloonphp/laravel-plugin: ^4.0||^5.0
- saloonphp/pagination-plugin: ^2.0
- saloonphp/saloon: ^4.4
- spatie/laravel-package-tools: ^1.19
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pint: ^1.20
- mockery/mockery: ^1.6
- nette/php-generator: ^4.1
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.0||^5.0
- symfony/var-dumper: ^7.2||^8.0
Suggests
- ext-zip: Reads the student and staff photos the Batch API sends as a zip
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package for the iSAMS REST API and Batch API, built with
Saloon. It has a request and typed models for every endpoint in iSAMS's
API reference, grouped by area: admissions(), agent(),
billing(), bulletin(), cloudPortals(), curriculum(), discipline(), estates(), exclusions(),
gradebooks(), humanResources(), medicalCentre(), news(), otherSchools(), registration(),
rewardsAndConduct(), school(), storage(), studentRegisters(), students(), systemConfiguration(),
teaching(), timetable() and wellbeingManager(). iSAMS's payment-provider webhooks are left out.
Requirements
- PHP 8.5
- Laravel 12.39 or higher, or Laravel 13
- iSAMS REST API credentials (a client id and client secret) for your school
Apps on PHP 8.3 or 8.4, on Laravel 11, or on Laravel 12 below 12.39, need to update to PHP 8.5 and Laravel 12.39 or later before updating to version 2.
Upgrading from version 1
Code written for version 1 keeps working, apart from the PHP and Laravel minimums above and the config file's publish tag (below). Some changes are worth making:
-
The early requests whose names don't follow the package's naming are deprecated in favour of generated ones, which return models:
Deprecated Use instead Students\GetStudentSystemStatusesRequestStudents\SystemStatuses\GetStudentSystemStatusesRequestStudents\Photos\GetCurrentPhotoBySchoolidRequestStudents\Photos\GetCurrentStudentPhotoRequestStudents\Photos\GetPhotosBySchoolidPhotoidRequestStudents\Photos\GetStudentPhotoRequestStudents\Contacts\GetContactsRequestStudents\Contacts\GetAllStudentContactsRequestStudents\Contacts\GetContactsBySchoolidRequestStudents\Contacts\GetStudentContactsRequestStudents\CustomFields\GetCustomFieldsBySchoolidRequestStudents\CustomFields\GetStudentCustomFieldsRequestStudents\CustomFields\GetCustomFieldsBySchoolidAndCustomfieldidRequestStudents\CustomFields\GetStudentCustomFieldRequestStudents\CustomFields\UpdateCustomFieldValueBySchoolidAndCustomfieldidRequestStudents\CustomFields\PatchStudentCustomFieldRequest(sends JSON, as iSAMS documents; the old request sends a form)Teaching\GetSubjectsRequestTeaching\Subjects\GetSubjectsRequestTeaching\GetSubjectByIdRequestTeaching\Subjects\GetSubjectRequestTeaching\GetSubjectTeachingDepartmentsBySubjectidRequestTeaching\SubjectTeachingDepartments\GetSubjectTeachingDepartmentsRequestTeaching\GetTeachingSetsRequestTeaching\Sets\GetTeachingSetsRequestTeaching\GetStudentsInSetBySetidRequestTeaching\Sets\GetTeachingSetStudentsRequestTeaching\GetCurriculumSubjectCodesRequestTeaching\CurriculumSubjectCodes\GetCurriculumSubjectCodesRequestBatchApi\GetEndpointRequest,BatchApi\FilterEndpointRequestIsams::batch()withBatch\BatchRequest(see Batch API) -
Students\GetStudentsRequestandStudents\GetStudentRequestkeep their names and now returnStudentmodels fromdto()(it returnednullbefore), anddto()throws anIsamsApiExceptionwhen iSAMS returned an error. -
When iSAMS refuses a connector's credentials, it now throws
Exceptions\UnauthorizedException, which says why (wrong id or secret, or a scope not allowed), instead of Saloon'sBadRequestExceptionorUnauthorizedException. Both are SaloonRequestExceptions, so code catching that still works. -
From 2 November 2026 iSAMS rejects Batch API requests that send only the key; the deprecated batch requests do that,
Isams::batch()does not. -
The config file is published with
php artisan isams-api:install, orvendor:publish --tag=isams-api-config.
Changes in 2.0.1
2.0.1 settles some of 2.0.0's names and behaviour:
- Batch API records that several root nodes share have one model,
Data\Batch\{Module}\{Node}, instead of a copy per root node:CalendarManager\Event(holdingCalendarManager\EventType),DisciplineManager\SessionandPupil,NewsManager\NewsItem,FileandBookmark,DailyBulletin\Bulletin,File,LinkedCategory,LinkedDivisionandLinkedYear,TimetableManager\Timetable,Period,Pupil,Schedule,StaffMeeting,Teacher,TutorialandYearSchedule(the published timetables keep their ownTimetableandSchedule, since their schedules differ),AgencyManager\Agency,Branch,Contact,PupilandVisit,SchoolManager\House,CoverManager\ReasonandPupilManager\CustomField. Change anyuseof the old classes under a root node's namespace. - A connector fetches its token before its first request instead of when it is built, so building one never contacts iSAMS, and refused credentials throw from the first request.
- Tokens are cached under a key per school, client and scopes, so tokens cached by 2.0.0 are not reused.
- A success whose body isn't JSON, such as a proxy's or maintenance page, throws a
ServerErrorException(aBatchApiExceptionfrom the Batch API) instead of PHP'sJsonException. - Paging through a response without
totalPagesthrows anUnexpectedValueExceptioninstead of stopping after the first page. - A downloaded
File'sfilenamenever includes a directory from iSAMS'sContent-Dispositionheader, andFilterrefuses a property name that isn't one.
Installation
composer require spkm/isams-api
Publish the config file if you want to change it:
php artisan isams-api:install
Then set your credentials in .env:
ISAMS_REST_CLIENT_ID=your-client-id ISAMS_REST_CLIENT_SECRET=your-client-secret ISAMS_BASE_URL=https://isams.your-school.example
IASMS_BASE_URL, the misspelt name used by earlier versions, is still read if ISAMS_BASE_URL is not set.
Usage
Two ways to call iSAMS
The package has two layers, as Laravel has Eloquent and, beneath it, the query builder:
| Resources: the Eloquent-like way | Requests: the Saloon way | |
|---|---|---|
| You write | Isams::teaching()->subjects()->all() |
Isams::send(new GetSubjectsRequest) |
| You get | the data: here a Collection of Subject models |
Saloon's Response, with status, headers and body |
| A list iSAMS pages | a LazyCollection that fetches pages as you loop |
one page; loop through them with Isams::paginate() |
| A write iSAMS answers without a body | true |
the response, with iSAMS's status |
| iSAMS returns an error | throws an IsamsApiException (see Errors) |
returns the failed response for you to check |
Use resources in application code. Use send() when you need the status or headers, or would rather check for errors
than catch them; $response->dto() turns its body into the same models. Each resource method's docblock names the
request it sends (@see), so your IDE takes you from one to the other.
use spkm\IsamsApi\Facades\Isams; use spkm\IsamsApi\Requests\Teaching\Subjects\GetSubjectsRequest; $subjects = Isams::teaching()->subjects()->all(); // Collection of Subject models $response = Isams::send(new GetSubjectsRequest); // Saloon\Http\Response, never throws if ($response->successful()) { $subjects = $response->dto(); }
Both use the configured credentials. Or build a connector yourself, which has the same resources and send():
use spkm\IsamsApi\IsamsConnector; $isams = new IsamsConnector($clientId, $clientSecret, $baseUrl);
The connector fetches an access token from iSAMS before its first request and caches it, encrypted, until shortly before it expires; building a connector never contacts iSAMS. A connector that outlives its token, as one in a queue worker does, fetches a new one before its next request.
Resources
Resources follow iSAMS's areas, and your IDE autocompletes them from Isams::. A resource's methods are named for
what they do: all() for a list, get() for one item, create(), update(), patch() and delete(), patchMany()
and the like for several at once, and actions of their own such as archive() or moveToAlumni(). Each returns what
iSAMS sent back. ENDPOINTS.md lists every call, with its parameters and what it returns:
use spkm\IsamsApi\Data\Teaching\SubjectForCreate; $subjects = Isams::teaching()->subjects()->all(); // Collection of Subject models $subject = Isams::teaching()->subjects()->get(7); // a Subject $created = Isams::teaching()->subjects()->create(new SubjectForCreate(code: 'MA', name: 'Mathematics')); $created->id; // '42': the new id from iSAMS's Location header (also ->location) $photo = Isams::students()->photos()->getCurrent('100001'); // File: contents, MIME type and filename Isams::teaching()->sets()->removeStudent(setId: 34, schoolId: '100001'); // true, as Eloquent's delete() returns Isams::systemConfiguration()->list()->contactTypes()->all(); foreach (Isams::students()->all() as $student) { // LazyCollection of Student models // }
Updates, deletes and the other writes iSAMS answers without a body return true, as Eloquent's update() and
delete() do. They never return false: when iSAMS refuses, they throw (see Errors), so reaching the next
line means iSAMS accepted the change.
Lists iSAMS sends in pages, such as every student, come back as a LazyCollection, like Eloquent's lazy(): each
page of 100 is fetched only when the loop reaches it, so the loop holds about one page at a time however large the
school. Nothing is sent until you loop, and calling all() or toArray() on it holds every record at once.
Path parameters always come in the order the URL has them.
Where iSAMS has changed an endpoint in a newer API version (so far, HR employee contracts), the plain request and
resource use the newest version; the older one sits in a V1 namespace and resource for anything the newer version
dropped:
Isams::humanResources()->employeeContracts()->all(employeeId: 40); // iSAMS API version 2.0 Isams::humanResources()->employeeContracts()->v1()->all(employeeId: 40); // version 1.0
Models
Resources, and dto() on a request's response, turn iSAMS's JSON into typed, read-only models: one model, a
Collection of models for lists, Created (the new record's location and id) for creations, or File for
downloads. Models are lenient: fields iSAMS leaves out are null, and fields the package doesn't know about are
ignored. Fields with a fixed set of values also have an enum helper, such as $student->genderEnum().
Errors
Resource calls and dto() throw an IsamsApiException when iSAMS returns an error, or one of its subclasses for the
statuses you are likely to handle differently:
| Status | Exception (in spkm\IsamsApi\Exceptions) |
Meaning |
|---|---|---|
| 404 | NotFoundException |
the record doesn't exist |
| 400, 409, 412, 415, 422 | InvalidRequestException |
iSAMS rejected what was sent; its message says why |
| 401, or a refused token request | UnauthorizedException |
iSAMS rejected the credentials, or the scopes asked for |
| 403, or 401 saying a permission is required | MissingPermissionException |
the API client's permission for that area is too low |
| 408, 5xx | ServerErrorException |
iSAMS timed out or failed; quote the trackingNumber() to iSAMS support |
| 2xx with a body that isn't JSON | ServerErrorException |
something other than iSAMS answered, such as a maintenance page |
| anything else, such as 301 ("exists but not associated") | IsamsApiException |
Each has iSAMS's isamsMessage() and trackingNumber(), the status from getStatus() and the failed response from
getResponse(), and its message names the request, so a log line says which call failed:
iSAMS responded 404 to GET /api/teaching/subjects/7: Failed to read subject - Subject does not exist.
Where iSAMS's own answer says little, the message adds what is known about it (see
ISAMS-API-DIFFERENCES.md): iSAMS answers a $filter it can't
apply with a bare 500 ("An error occurred."), and a path it doesn't have with an HTML error page rather than a 404.
A refused token request says why (a wrong client id or secret, or a scope the client isn't allowed). iSAMS sets each API client's permission per area (None, Read, Write, Modify or Full), so a
MissingPermissionException also says which permission is needed and where:
use spkm\IsamsApi\Exceptions\MissingPermissionException; use spkm\IsamsApi\Exceptions\NotFoundException; try { $subject = Isams::teaching()->subjects()->get(7); } catch (NotFoundException) { $subject = null; } catch (MissingPermissionException $exception) { $exception->requiredPermission(); // "Read" $exception->area(); // "Teaching" }
send() never throws. Isams::paginate() and $response->throw() throw Saloon's own exceptions, as Saloon does.
Paging through results
Resources page for you. To page through a request yourself:
use spkm\IsamsApi\Requests\Students\GetStudentsRequest; foreach (Isams::paginate(new GetStudentsRequest)->dtos() as $student) { // $student is a Student model; collect() gives each item as an array instead. }
As with resources, each page is fetched only when the loop reaches it. A response without totalPages isn't a paged
list, so paging through one throws an UnexpectedValueException: send the request instead.
Filtering and updating
use spkm\IsamsApi\Support\Filter; use spkm\IsamsApi\Support\JsonPatch; $filter = Filter::where('surname', '=', "O'Brien")->and('enrolmentDate', '>=', today()->subYear()); // Surname eq 'O''Brien' and EnrolmentDate ge datetime'2025-10-31' $patch = JsonPatch::make()->replace('/preferredName', 'Alex');
Filter produces iSAMS's OData $filter syntax and can be passed wherever a request takes a filter. It takes
Laravel's comparison operators (=, != or <>, >, >=, < and <=) and turns them into iSAMS's, or iSAMS's own
(eq, ne, gt, ge, lt and le). iSAMS supports no others, so like and in throw. Values are escaped, but
property names are not values: a name must be letters, digits and underscores (with / for a nested property), and
anything else throws, so never build one from user input. iSAMS's PATCH endpoints take a JsonPatch.
Endpoints iSAMS adds later
Call any iSAMS path relative to your base URL:
$groups = Isams::get('/api/students/customgroups', ['$filter' => "Name eq 'Choir'"])->json(); Isams::post('/api/students/customgroups', ['name' => 'Choir']); foreach (Isams::paginateEndpoint('/api/students/customgroups', 'customGroups')->collect() as $group) { // }
put(), patch() and delete() work the same way. The older ConfigurableRequest and ConfigurablePaginatedRequest
still work too.
Several schools
Add named connections to config/isams-api.php:
'connections' => [ 'prep' => [ 'client_id' => env('ISAMS_PREP_CLIENT_ID'), 'client_secret' => env('ISAMS_PREP_CLIENT_SECRET'), 'base_url' => env('ISAMS_PREP_BASE_URL'), ], ],
Isams::connection('prep')->teaching()->subjects()->all();
The top-level client_id, client_secret and base_url remain the default connection.
Batch API
iSAMS's Batch API returns large read-only exports, one set of methods per Batch API key. Each key has its own client secret; the package fetches a token for the key and sends the key with every request.
iSAMS recommends one key for everything, choosing the methods each request runs. Set it in .env:
ISAMS_BATCH_API_KEY=your-batch-api-key ISAMS_BATCH_CLIENT_SECRET=your-batch-api-client-secret
If your school uses separate keys, add them by name to batch_keys in config/isams-api.php (the published file
has a commented-out example), at the top level for the default school or under a named connection.
As with REST, there are two ways to call it. Each Batch API method is one call that returns its records as a lazy
collection of models, grouped by the part of the method's name before the underscore (Pupil_GetCurrentPupils is
pupil()->currentPupils()), with the method's filters as named parameters. iSAMS sends the whole export in one
response, and each model is built as the loop reaches it, so a large export (tens of thousands of exam results) doesn't
hold every model at once. A call throws a BatchApiException when iSAMS refuses, and an InvalidArgumentException,
without asking iSAMS, when a filter iSAMS requires is given no values:
$students = Isams::batch()->pupil()->currentPupils(); // LazyCollection of Pupil models $passports = Isams::batch()->pupil()->passports(systemStatusesToInclude: [0, 1]); // filtered by system status $terms = Isams::batch()->school()->terms(); $staff = Isams::batch()->humanResources()->currentStaff(); $timetables = Isams::batch('timetable')->timetable()->published(); // a separately named key
ENDPOINTS.md lists every call, with its filters and what it returns.
The Batch API can also run several methods in one request, the Saloon way: send a BatchRequest naming them, with
filters for those that take them, and read each method from the response:
use spkm\IsamsApi\Batch\BatchFilters; use spkm\IsamsApi\Batch\BatchRequest; $batch = Isams::batch()->send(new BatchRequest( BatchFilters::make() ->methods('Pupil_GetCurrentPupils', 'Pupil_GetPassports') ->filter('StudentManager', 'Passports', ['SystemStatusesToInclude' => [0, 1]]) ))->dto(); $students = $batch->method('Pupil_GetCurrentPupils'); $passports = $batch->method('Pupil_GetPassports'); $everything = Isams::batch()->send(new BatchRequest)->dto(); // every method allocated to the key
Running every method at once only works for a key whose methods need no filters: iSAMS refuses the whole request
(FiltersRequiredException) if any allocated method requires one, such as Calendar_GetEvents.
method() returns a collection of models: Data\Batch\{Module}\{Node} for records several root nodes share, such as
the calendar's events this week, this month and so on, otherwise Data\Batch\{Module}\{RootNode}\{Node}.
models($module, $rootNode) does the same by node name, and get($module, $rootNode) returns plain arrays for
anything the package has no model for. A filter's module and element names are the ones iSAMS documents for filters,
which can differ from the method's (for example Pupil_GetPassports is filtered under StudentManager); the one-call
methods use them for you.
cachedSecondsAgo() says whether iSAMS served the response from its cache. A failed request's dto() throws a
BatchApiException with iSAMS's error name, title and description; its isPermissionError() is true when the key is
unknown, revoked or not allocated the methods asked for.
Photos come as a zip, read by the student's or staff member's id:
foreach (Isams::batch()->studentPhotos()->photos() as $id => $png) { // }
staffPhotos() does the same for staff.
The older GetEndpointRequest and FilterEndpointRequest still work but are deprecated: from 2 November 2026 iSAMS
only accepts Batch API requests that authenticate with the key's own client secret.
Cloud Portals
The Cloud Portals endpoints (Isams::cloudPortals(), Requests\CloudPortals\…) are the ones iSAMS's parent and
student portals use. iSAMS's spec lists them under the usual restapi scope, while its portal documentation names a
separate iSAMS.CloudPortals.Api scope; if your credentials need that scope, give them a connection of their own:
'connections' => [ 'portals' => [ 'client_id' => env('ISAMS_PORTALS_CLIENT_ID'), 'client_secret' => env('ISAMS_PORTALS_CLIENT_SECRET'), 'base_url' => env('ISAMS_BASE_URL'), 'scopes' => ['iSAMS.CloudPortals.Api'], ], ],
Isams::connection('portals')->cloudPortals()->school()->bookmark()->all();
Many of them act for a signed-in portal user and are untyped in iSAMS's spec, so they return arrays; treat them as unverified until you have tried them against your own iSAMS.
When iSAMS is slow or offline
All of these are off unless you configure them (see config/isams-api.php):
- Timeouts:
ISAMS_CONNECT_TIMEOUTandISAMS_REQUEST_TIMEOUT(seconds; 10 and 30 by default). Lower them for web requests so pages don't hang. - Circuit breaker:
ISAMS_CIRCUIT_BREAKER=true. After repeated connection failures or 502, 503 and 504 responses, requests fail instantly with anIsamsUnavailableexception for a cooldown period instead of waiting for timeouts. 500s count neither way, since iSAMS also sends them for a filter it can't apply.Isams::isAvailable()tells you, without contacting iSAMS, whether requests are currently paused. - Retries:
ISAMS_RETRIES=3retries reads (GET requests and Batch API calls) that fail with a server error, rate limit or connection error. Writes are never retried, and nor are the few GETs that make iSAMS do something, such as running wellbeing workflows. - Re-authentication:
ISAMS_REAUTHENTICATE=truefetches a new token and repeats the request once when iSAMS rejects a cached token, but not when it refuses for want of a permission. - Caching:
ISAMS_CACHE=truecaches slow-changing reference lists (terms, houses, year groups, forms, divisions, departments, buildings, rooms and the timetable structure) in your Laravel cache, for an hour by default.
Testing
composer test
composer analyse
composer format:check
The tests never contact a real iSAMS: every response is faked.
Checking the package against your iSAMS sandbox
iSAMS's documentation isn't always what its API does; ISAMS-API-DIFFERENCES.md lists what these checks have found so far, and how the package handles each difference.
The separate Sandbox test suite calls your own iSAMS sandbox. It reads every list it can without arguments and every
endpoint it can reach with ids from those lists, runs each Batch API method allocated to a key, checks paging, and
provokes errors with reads (an unknown record, a malformed filter, a forged token). It reports the fields that differ
from the package's models, values a model can't read, endpoints the sandbox doesn't offer, and what iSAMS sends for
each error.
ISAMS_SANDBOX_BASE_URL=https://your-sandbox.example ISAMS_SANDBOX_CLIENT_ID=your-client-id ISAMS_SANDBOX_CLIENT_SECRET=your-client-secret # optional ISAMS_SANDBOX_SCOPES="restapi" ISAMS_SANDBOX_BATCH_KEY=your-batch-api-key ISAMS_SANDBOX_BATCH_SECRET=your-batch-api-secret
vendor/bin/pest --testsuite=Sandbox
The checks only read: anything else is refused unless ISAMS_SANDBOX_ALLOW_WRITES=true, and the few GETs that make
iSAMS do something (such as running wellbeing workflows) are never sent. Only allow writes on a sandbox whose data you
may change.
Contributing
Most requests and models are generated from iSAMS's API spec, pinned in generator/spec. Don't edit files in
generated/: change generator/overrides.php (or the generator) and run composer generate.
composer generate:check fails if the generated code is out of date. The Batch API models are generated from
generator/batch/nodes.php, the node lists from iSAMS's Batch API documentation.
generator/README.md explains how the generator works, what each override does and how to add one.
Security
If you discover any security related issues, please email hello@spkmitchell.co.uk instead of using the issue tracker.
Credits
Licence
The MIT Licence (MIT).