funstaff/ref-lib-ris
A PHP package for working with RIS bibliographic reference files. Parse, build, and export citation records such as journal articles, books, and conference papers, with a simple API that fits neatly into Laravel applications. Handy when importing or generating reference data.
Install and pin. There is no service provider, no facade, no config file — nothing to discover with php artisan. Installing it is a single Composer line, and the only setup that matters is pinning:
composer require funstaff/ref-lib-ris:^1.0 # then edit composer.json to an exact version, e.g. "1.0.3"
Before you commit, check vendor/funstaff/ref-lib-ris/composer.json for the PHP constraint and any ext-* requirements. This package has effectively zero adoption, so treat its declared constraints as unverified claims rather than a compatibility guarantee.
Where to look first. The library is small enough to read in one sitting — do that before writing any application code:
vendor/funstaff/ref-lib-ris/src/ — find the one class that actually emits text. Everything else is probably tag constants and small DTOs.mb_, strlen, \n, and ER to answer the three questions that decide whether this is usable: does it handle multibyte strings, does it fold long lines, and does it terminate records correctly?First use case: one record, one file, imported into Zotero. Do this before wiring anything into your app. A throwaway Artisan command is enough:
// app/Console/Commands/RisSpikeCommand.php
public function handle(): int
{
$ris = app(\App\Services\Citation\RisExporter::class)->exportOne(
title: 'On the Origin of Species',
authors: ['Darwin, Charles'],
journal: 'Nature',
year: 1859,
doi: '10.1000/example',
);
$this->line($ris); // eyeball it
file_put_contents(storage_path('app/spike.ris'), $ris);
return self::SUCCESS;
}
Then import spike.ris into Zotero and EndNote. That import — not a passing unit test — is the acceptance criterion for the whole integration. If it fails on accents, an abstract, or two authors, stop and decide between fixing the output yourself and writing the ~150-line formatter in-house.
First real endpoint. Once the spike passes, wrap it behind an interface you own and expose a download:
Route::get('/articles/{article}/citation.ris', ExportCitationController::class)
->name('articles.citation');
The handler should be three lines: resolve the exporter from the container, build the record, return a response with Content-Type: text/plain; charset=UTF-8 and Content-Disposition: attachment; filename="article-{$article->id}.ris".
Interface boundary, always. This package must never appear in a controller, model, Blade template, or Livewire component. Define your own contract and make the package one implementation of it:
namespace App\Services\Citation;
interface CitationExporter
{
/** @param array<int, Reference> $references */
public function export(array $references): string;
}
namespace App\Services\Citation;
use Funstaff\RefLibRis\...; // the one class you found in src/
class RisCitationExporter implements CitationExporter
{
public function export(array $references): string
{
$out = '';
foreach ($references as $ref) {
$out .= $this->exportOne($ref) . "\r\n"; // blank line between records
}
return $out;
}
// mapping lives here and nowhere else
}
Bind it in AppServiceProvider::register() with $this->app->bind(CitationExporter::class, RisCitationExporter::class);. A singleton is fine — the writer is stateless — but only if you have verified it holds no internal buffer between calls.
Own the field mapping explicitly. Serialization is the package's job; tag selection is yours. Write the mapping down as a table in the adapter's docblock, because this is where every bug will live:
| Domain | RIS | Notes |
|---|---|---|
| title | TI |
Also emit T1 for some consumers |
| authors[] | AU |
One tag per author, Last, First |
| journal | JO / T2 |
JF full, JO abbreviated |
| year | PY / Y1 |
Year only, no month |
| volume / issue | VL / IS |
Strings, not ints |
| pages "123-130" | SP 123, EP 130 |
Split; do not put the range in one tag |
| doi | DO |
Also UR if you must, but DO is what Zotero reads |
| url | UR |
|
| abstract | AB |
Long — check line folding |
| type | TY |
JOUR, BOOK, CONF, THES, RPRT… |
Store that mapping in a plain array-driven method (Reference::toRisArray()) so it can be unit-tested without instantiating the package at all.
Per-record download. The common case. Keep it synchronous, no queue:
return response($exporter->export([$article->toReference()]), 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="citation.ris"',
]);
Use response()->streamDownload() with a generator if you want a consistent download UX, but for one record the plain response is fine and easier to test.
Bulk export → queue → Storage. Never build a 10,000-record string inside a web request. Push a job that writes to disk and notifies the user with a signed URL:
class ExportCitationsJob implements ShouldQueue
{
public function handle(CitationExporter $exporter): void
{
$path = "exports/{$this->export->id}.ris";
$handle = Storage::disk('local')->writeStream($path, null);
Article::query()->whereIn('id', $this->articleIds)->lazyById(500)
->each(function ($article) use ($exporter, $handle) {
fwrite($handle, $exporter->export([$article->toReference()]) . "\r\n");
});
fclose($handle);
}
}
Chunked generation via lazyById + writeStream bounds memory regardless of what the library does internally. Confirm the exporter does not accumulate state between calls — if it does, instantiate it per chunk.
Attach to mail. Submission confirmations often want the .ris inline. Use attachData rather than writing a temp file:
$mail->attachData($exporter->export([$article->toReference()]), 'citation.ris', [
'mime' => 'application/x-research-info-systems',
]);
application/x-research-info-systems is the correct MIME type and improves how mail clients and OS file associations handle the attachment.
Golden-file tests. This is the highest-value thing you will write. Commit reference .ris files produced by Zotero from known input, and assert your exporter matches byte-for-byte (or tag-for-tag after normalizing line endings):
it('matches the Zotero golden file', function () {
$actual = app(CitationExporter::class)->export([$this->darwinReference()]);
expect($actual)->toBe(file_get_contents(base_path('tests/fixtures/darwin.ris')));
});
Add a dataset with the nasty cases: diacritics (Müller, Ñuño), a 2,000-character abstract, a corporate author, a book with no journal, and a record with an empty DOI. Those five cover most real-world breakage.
Feature flag it. If the export is user-facing, ship it behind a flag. It makes the rollback story trivial if a reference manager rejects the output in the field, and it lets you compare against an in-house writer without touching product code.
The description and the assessment disagree about parsing. Marketing text says "parse, validate, and generate"; the product assessment is emphatic that this is writer-only. Grep the source before you promise anyone an import feature. If it really is writer-only, an import requirement is a different ticket with a different solution.
Line folding is the #1 cause of rejected .ris files. RIS physical lines should stay under roughly 255 characters, with continuation lines starting with a space. A 1,500-character abstract emitted as one giant line will import into some tools and silently truncate in others. Test with a real long abstract and inspect with cat -A or open it in a plain-text editor with wrapping off.
Terminator and whitespace are load-bearing. TY must be the first tag, ER - (note the two spaces after the tag and the space-dash-space separator) must be the last, and each record should be followed by a blank line. Files that look right in an editor but lack the trailing blank line will merge records in some consumers. Do not "tidy" the output with a formatter.
Encoding, end to end. Set the database connection and column collations to utf8mb4, and assert the exporter does not mangle multibyte input. Do not add a UTF-8 BOM — it looks like a stray tag to some parsers. If you are pulling data from a legacy source, normalize to UTF-8 before it reaches the exporter, not after. A round-trip test with Müller, Škoda, and one CJK title catches almost everything.
Line endings. Many reference managers tolerate \n, some Windows tools prefer \r\n. Emit \r\n and terminate records with \r\n\r\n, then normalize in tests so the fixtures are readable in git. Document the choice — a future contributor will "fix" it otherwise.
Author name forms are not interchangeable. AU with Last, First for personal names; institutional authors typically need a trailing comma (World Health Organization,) to stop parsers splitting on the space. Check whether the library accepts pre-formatted strings or tries to parse "First Last" itself — if it parses, feed it already-formatted Last, First and verify the output does not double-comma.
Pages need to be split. SP/EP, not one pages field. Splitting "123-130" is trivial until you hit "e12345", "Suppl 2", or an en-dash pasted from Word. Normalize – to - and handle single-page records (SP only, no EP).
DOI belongs in DO, not just UR. Zotero will not populate the DOI field from UR. Emit both if you like, but DO is the one that matters.
Memory on bulk exports. The package builds strings in memory, which is fine per record and dangerous per result set. Use lazyById or cursor(), never ::all(). Test with 10,000 synthetic records before shipping "download all results" and watch peak memory in the job.
Silent data loss is the sneaky failure. If a domain field has no mapping, nothing throws — the citation is just quietly poorer. Assert coverage: keep a list of exported domain attributes and fail a test when a new one appears without a mapping entry. Log a warning at export time for unmapped attributes.
There is nothing to configure, so don't try. No config/ref-lib-ris.php will appear, and php artisan vendor:publish will list nothing. Resist wrapping it in a facade — a facade over a five-method adapter hides the swap boundary you deliberately created.
Assume you will fork it. One star, zero dependents, no release cadence. If the source is a handful of files, copy it into app/Support/Ris/ with a // FORKED FROM funstaff/ref-lib-ris @ <sha> header and delete the dependency. MIT makes this free, the golden-file tests make it safe, and you remove the supply-chain question entirely. Budget half a day; it is usually the right call for a library this small.
Debugging workflow. Write the raw output to a file, then check three things in order: cat -A for invisible whitespace and line endings, an import into Zotero for structural acceptance, and a diff against the golden fixture for regression. Do not debug by staring at a JSON response — escape sequences will lie to you.
Never dd() an RIS payload. It is plain text with meaningful leading and trailing whitespace; a browser or terminal will hide exactly the characters that are broken. Use Storage::put or file_put_contents and open the file.
How can I help you explore Laravel packages today?