jeroendesloovere/vcard
A lightweight PHP library for creating and exporting vCard (.vcf) files. Build contacts with names, addresses, phone numbers, emails, URLs, photos, and custom fields via a simple API. Framework-agnostic and easy to integrate into Laravel or any PHP project.
Install with Composer — the package is a plain library with no framework coupling, so it drops into Laravel, Symfony, or a bare PHP script unchanged:
composer require jeroendesloovere/vcard
There are two entry points, and knowing which one you need is the whole onboarding:
Jeroendesloovere\VCard\VCard — the builder. You instantiate it, call add*() methods, and get a .vcf string back.Jeroendesloovere\VCard\VCardParser — the parser. You feed it raw vCard text or a file path and get an array of cards back.First use case, generating a single contact and returning it as a download:
use Jeroendesloovere\VCard\VCard;
$vcard = new VCard();
$vcard->addName('Doe', 'John'); // lastName, firstName
$vcard->addCompany('Acme Ltd');
$vcard->addJobTitle('Developer');
$vcard->addEmail('[email protected]', 'WORK');
$vcard->addPhoneNumber('+32 123 45 67', 'WORK;VOICE');
$vcard->addUrl('https://acme.test');
return response($vcard->getOutput(), 200, [
'Content-Type' => 'text/vcard; charset=utf-8',
'Content-Disposition' => 'attachment; filename="john-doe.vcf"',
]);
Do not start with ->download() inside a controller if you plan to unit-test the output — download() writes headers and exits the normal response flow. Prefer getOutput() and let Laravel build the response; it is far easier to assert on in tests.
Read the VCard.php source once before you build anything. The builder is small and every property you can emit is visible there — there is no documentation site, and the README only covers the happy path.
Keep the library at the edges. The biggest long-term win is never letting VCard objects leak into your domain. Define your own DTO or use an Eloquent model, and write a one-directional mapper:
final class ContactToVCard
{
public function build(Contact $contact): string
{
$vcard = new VCard();
$vcard->addName($contact->last_name, $contact->first_name);
$vcard->addEmail($contact->email, 'WORK');
foreach ($contact->phones as $phone) {
$vcard->addPhoneNumber($phone->number, $phone->type ?: 'VOICE');
}
return $vcard->getOutput();
}
}
This isolates you from the package's 2023-era API and makes swapping to sabre/vobject a single-file change if you outgrow it.
Directory export by concatenation. One VCard instance equals one card. For a team/company export, loop and concatenate:
$cards = $contacts->map(fn ($c) => app(ContactToVCard::class)->build($c));
return response($cards->implode(''), 200, [
'Content-Type' => 'text/vcard; charset=utf-8',
'Content-Disposition' => 'attachment; filename="team.vcf"',
]);
Every card carries its own BEGIN:VCARD/END:VCARD, so plain concatenation is valid and is how Google Contacts and Outlook expect a multi-contact file.
Import as an upload endpoint. Receive the .vcf, parse, normalise, persist:
use Jeroendeslovere\VCard\VCardParser;
$parser = new VCardParser();
$cards = $parser->parse($request->file('vcf')->getContent());
foreach ($cards as $card) {
// $card is an array of property => values; field names vary by source device.
}
Treat the parsed shape as untrusted and irregular. A card from an iPhone, one from Outlook via Outlook's quoted-printable export, and one generated by this library will all produce different key sets. Always run the output through a normalisation layer that maps TEL/PHONE/CELL variants into your own schema, and wrap the import in a job if files can be large.
Round-trip as your regression test. The cheapest safety net for a package with this release cadence is a fixture test: generate a card, snapshot getOutput(), and assert against the file. When you upgrade, the diff tells you exactly what broke. Pair it with an import test using a real .vcf captured from an iPhone and from Outlook.
Batch/large exports go through storage, not the request. Generating hundreds of cards with embedded photos is slow and memory-hungry. Build into a temporary file, Storage::disk('local')->put(), and hand the user a signed URL. It also gives you a cacheable artefact instead of regenerating on every click.
Photo handling. addPhoto() takes a filesystem path to a local image; it reads the file and base64-embeds it into the card. With Laravel's Storage (S3, etc.) there is no local path, so stage the bytes first:
$tmp = tempnam(sys_get_temp_dir(), 'vcf');
file_put_contents($tmp, Storage::disk('s3')->get($contact->avatar_path));
$vcard->addPhoto($tmp);
unlink($tmp);
Resize before embedding — see the gotchas below.
addAddress() argument order is the #1 bug. It follows the vCard wire order, not human order: post office box, extended address, street, locality, region, postal code, country, type. Developers routinely put the street in the second slot and get silently wrong cards, because nothing validates it. Wrap it in your own addAddress($contact) helper once and never call it raw again.
Line folding and line endings. vCard requires CRLF and folding at 75 octets. Some consumers (Outlook in particular) are strict about long NOTE/ADR fields. If a card fails to import in one client but works elsewhere, unfold/refold check is the first thing to inspect. Do not trim() or run str_replace("\n", ...) on getOutput() — you will break the CRLF structure.
Escaping is your responsibility in free text. Commas, semicolons and backslashes are structural in vCard 3.0/4.0. NOTE: and ORG: values containing ; will split into extra fields on import. Sanitise or escape those values in your mapper rather than trusting the library to do it consistently across versions.
Static configuration leaks in long-running processes. Defaults like charset, encoding, file extension and mime type are set through static setters on VCard. Under Octane, Horizon workers, or a queued export, a call to setDefaultCharset() in one job persists into the next. Set them explicitly at the top of every job, or only ever set them in a service provider's boot().
download() and headers. It sends Content-Type/Content-Disposition and body directly. Any prior output — a stray echo, a BOM in a config file, whitespace after ?> — corrupts the .vcf. Use getOutput() and a normal Laravel response instead; you also avoid the "headers already sent" class of bug entirely.
Sanitise the filename you pass to download(). It becomes a Content-Disposition header. Strip CR/LF and quotes from anything user-derived, or you have header injection.
Parsing pitfalls, in order of how often they bite:
ENCODING=QUOTED-PRINTABLE. If accented names come back as =C3=A9, the parser hasn't decoded it and you need quoted_printable_decode() in your normaliser.item1.TEL;type=CELL:.... The item1. prefix must be stripped before key matching, or those properties are silently lost.TEL;TYPE=WORK,VOICE: and TEL;TYPE="WORK,VOICE": are the same thing; naive explode(',') on the raw line will not always agree.EMAIL can appear many times per card, but FN and N should not. Decide which wins before writing to the database, and enforce a unique constraint on email so imports are idempotent.CHARSET=ISO-8859-1 must be converted to UTF-8 before you store them, or you will get mojibake that only surfaces months later in a search.Embedded photos bloat files fast. Base64 adds ~33%, and a 2 MB phone photo produces a card no client will render as an avatar. Cap avatars at roughly 96–200 KB and resize (Intervention Image, Imagick) before calling addPhoto(). Skip the photo entirely for bulk exports.
Version differences are real. The default is vCard 3.0, which is the pragmatic choice for compatibility. If you switch to 4.0 for properties like ANNIVERSARY or GENDER, verify your photo parameter syntax (PHOTO:data:image/jpeg;base64,... in 4.0 vs PHOTO;ENCODING=b;TYPE=JPEG: in 3.0) and re-test in Outlook, which lags on 4.0 support.
No validation anywhere. The library will happily emit EMAIL: with an empty value, a non-URL in URL:, or a card with no FN — which is a required property in 3.0 and will be rejected by stricter parsers. Validate in your mapper: require a name, require at least one of email/phone, skip empty optional fields rather than emitting them blank.
Extension points are limited by design. There is no event system, no formatter interface, no way to register a custom property cleanly. If you need a property the builder doesn't expose, the practical options are (a) subclass VCard and add your own method that appends the raw line before END:VCARD, or (b) migrate to sabre/vobject, which models the format properly and is actively maintained. Choose (a) for one or two fields, (b) the moment you find yourself fighting the API for the third time.
Version your fixtures, not your assumptions. With a last release in September 2023, treat this package as stable-but-static. Pin the exact version in composer.json ("jeroendesloovere/vcard": "3.0.*" or whatever your resolved version is) rather than using a caret range, because you will have no upstream roadmap telling you when a breaking change lands.
How can I help you explore Laravel packages today?