vCard download response
Build an RFC 6350 compliant contact card with escaping and line folding, and return it as a downloadable .vcf file.
LaravelUtilitiesvcardcontactsdownloadresponse
Code
<?php
namespace App\Contacts;
use Symfony\Component\HttpFoundation\Response;
final class VcardBuilder
{
/** @param array{first:string,last:string,org?:string,title?:string,phone?:string,email?:string,url?:string,note?:string} $c */
public static function build(array $c): string
{
$lines = [
'BEGIN:VCARD',
'VERSION:3.0',
'N:'.self::esc($c['last']).';'.self::esc($c['first']).';;;',
'FN:'.self::esc(trim($c['first'].' '.$c['last'])),
];
// Optional properties are skipped when empty so the card stays clean.
$optional = [
'ORG' => $c['org'] ?? null,
'TITLE' => $c['title'] ?? null,
'TEL;TYPE=CELL' => $c['phone'] ?? null,
'EMAIL;TYPE=INTERNET' => $c['email'] ?? null,
'URL' => $c['url'] ?? null,
'NOTE' => $c['note'] ?? null,
];
foreach ($optional as $prop => $value) {
if (filled($value)) {
$lines[] = $prop.':'.self::esc((string) $value);
}
}
$lines[] = 'REV:'.gmdate('Ymd\THis\Z');
$lines[] = 'END:VCARD';
return implode("\r\n", array_map([self::class, 'fold'], $lines))."\r\n";
}
public static function download(array $contact): Response
{
$name = preg_replace('/[^a-z0-9]+/i', '-', $contact['first'].'-'.$contact['last']);
return response(self::build($contact), 200, [
'Content-Type' => 'text/vcard; charset=utf-8',
'Content-Disposition' => 'attachment; filename="'.trim($name, '-').'.vcf"',
]);
}
/** Escape the characters that carry meaning in a vCard value. */
private static function esc(string $v): string
{
return str_replace(["\\", ";", ",", "\r\n", "\n"], ["\\\\", "\;", "\\,", "\\n", "\\n"], $v);
}
/** Lines over 75 octets are folded: CRLF plus one space continues the line. */
private static function fold(string $line): string
{
$out = '';
while (strlen($line) > 75) {
$cut = 75;
while ($cut > 0 && (ord($line[$cut]) & 0xC0) === 0x80) {
$cut--; // never split a multi-byte UTF-8 character
}
$out .= substr($line, 0, $cut)."\r\n ";
$line = substr($line, $cut);
}
return $out.$line;
}
}
How it works
What it solves
A "Save contact" button on a profile page, a digital business card or an event badge page needs a .vcf file that phones import correctly. Hand-concatenated strings break as soon as a name contains a comma, a semicolon or an accented letter.
How it works
build() assembles the card line by line in vCard 3.0, which is accepted by iOS, Android and desktop address books. Required fields are the structured name N and the display name FN. Optional properties live in a map and are emitted only when they hold a value.
Two small helpers do the careful work. esc() escapes backslashes, semicolons, commas and line breaks, the characters that otherwise split a field. fold() wraps lines longer than 75 bytes by inserting CRLF and a space, as the specification requires, and steps back over UTF-8 continuation bytes so a multi-byte character is never cut in half. Lines are joined with CRLF.
download() returns the card with text/vcard and an attachment disposition. The filename is sanitised to letters, digits and dashes.
Adapting it
- Add
ADR,BDAYor social profileX-SOCIALPROFILElines to the optional map. - Embed a photo as base64
PHOTO;ENCODING=b;TYPE=JPEG, folding the long line. - Serve it from a route such as
/card/{slug}.vcfand cache it for a minute.
Pitfalls
- Use CRLF, not LF; some importers silently reject LF-only files.
- Never trust the name when building the filename, as the sanitising step above shows.
How to use it
- Put
VcardBuilder.phpinapp/Contacts/. - Add a route:
Route::get('/card/{profile}.vcf', fn (Profile $p) => VcardBuilder::download($p->only('first','last','org','title','phone','email','url'))); - Link to it:
<a href="/card/ada.vcf" download>Save contact</a>. - To store or email the card instead, call
VcardBuilder::build($contact)for the raw string.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.